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 bootstrap_workspace_uses_default_internal_window_size() {
5220 let model = AppModel::new("Main");
5221 let workspace = model.active_workspace().expect("workspace");
5222 let column = workspace.columns.values().next().expect("column");
5223 let window = workspace.windows.values().next().expect("window");
5224
5225 assert_eq!(column.width, DEFAULT_WORKSPACE_WINDOW_WIDTH);
5226 assert_eq!(window.height, DEFAULT_WORKSPACE_WINDOW_HEIGHT);
5227 assert_eq!(WindowFrame::root().width, DEFAULT_WORKSPACE_WINDOW_WIDTH);
5228 assert_eq!(WindowFrame::root().height, DEFAULT_WORKSPACE_WINDOW_HEIGHT);
5229 }
5230
5231 #[test]
5232 fn creating_workspace_windows_creates_columns_and_stacks() {
5233 let mut model = AppModel::new("Main");
5234 let workspace_id = model.active_workspace_id().expect("workspace");
5235 let first_window_id = model
5236 .active_workspace()
5237 .map(|workspace| workspace.active_window)
5238 .expect("window");
5239
5240 let right_pane = model
5241 .create_workspace_window(workspace_id, Direction::Right)
5242 .expect("window created");
5243 let stacked_pane = model
5244 .create_workspace_window(workspace_id, Direction::Down)
5245 .expect("stacked window created");
5246 let workspace = model.workspaces.get(&workspace_id).expect("workspace");
5247 let right_column = workspace
5248 .columns
5249 .values()
5250 .find(|column| column.window_order.contains(&workspace.active_window))
5251 .expect("active column");
5252
5253 assert_eq!(workspace.windows.len(), 3);
5254 assert_eq!(workspace.columns.len(), 2);
5255 assert_eq!(workspace.active_pane, stacked_pane);
5256 assert_eq!(right_column.width, DEFAULT_WORKSPACE_WINDOW_WIDTH / 2);
5257 assert_eq!(right_column.window_order.len(), 2);
5258 assert_ne!(workspace.active_window, first_window_id);
5259 assert!(workspace.columns.values().any(|column| {
5260 column.window_order == vec![first_window_id]
5261 && column.width == DEFAULT_WORKSPACE_WINDOW_WIDTH / 2
5262 }));
5263 let upper_window_id = right_column.window_order[0];
5264 assert_eq!(
5265 workspace
5266 .windows
5267 .get(&upper_window_id)
5268 .expect("window")
5269 .active_pane()
5270 .expect("active pane"),
5271 right_pane
5272 );
5273 assert_eq!(
5274 workspace
5275 .windows
5276 .get(&upper_window_id)
5277 .expect("window")
5278 .height,
5279 (DEFAULT_WORKSPACE_WINDOW_HEIGHT + 1) / 2
5280 );
5281 assert_eq!(
5282 workspace
5283 .windows
5284 .get(&workspace.active_window)
5285 .expect("window")
5286 .height,
5287 DEFAULT_WORKSPACE_WINDOW_HEIGHT / 2
5288 );
5289 }
5290
5291 #[test]
5292 fn creating_workspace_window_clamps_split_column_width_to_minimum() {
5293 let mut model = AppModel::new("Main");
5294 let workspace_id = model.active_workspace_id().expect("workspace");
5295 let workspace = model.active_workspace().expect("workspace");
5296 let column_id = workspace.active_column_id().expect("active column");
5297
5298 model
5299 .set_workspace_column_width(workspace_id, column_id, MIN_WORKSPACE_WINDOW_WIDTH + 80)
5300 .expect("set width");
5301 model
5302 .create_workspace_window(workspace_id, Direction::Right)
5303 .expect("window created");
5304
5305 let workspace = model.workspaces.get(&workspace_id).expect("workspace");
5306 let widths = workspace
5307 .columns
5308 .values()
5309 .map(|column| column.width)
5310 .collect::<Vec<_>>();
5311 assert_eq!(
5312 widths,
5313 vec![MIN_WORKSPACE_WINDOW_WIDTH, MIN_WORKSPACE_WINDOW_WIDTH]
5314 );
5315 }
5316
5317 #[test]
5318 fn creating_workspace_window_clamps_split_window_height_to_minimum() {
5319 let mut model = AppModel::new("Main");
5320 let workspace_id = model.active_workspace_id().expect("workspace");
5321 let window_id = model
5322 .active_workspace()
5323 .map(|workspace| workspace.active_window)
5324 .expect("active window");
5325
5326 model
5327 .set_workspace_window_height(workspace_id, window_id, MIN_WORKSPACE_WINDOW_HEIGHT + 50)
5328 .expect("set height");
5329 model
5330 .create_workspace_window(workspace_id, Direction::Down)
5331 .expect("window created");
5332
5333 let workspace = model.workspaces.get(&workspace_id).expect("workspace");
5334 let heights = workspace
5335 .windows
5336 .values()
5337 .map(|window| window.height)
5338 .collect::<Vec<_>>();
5339 assert_eq!(
5340 heights,
5341 vec![MIN_WORKSPACE_WINDOW_HEIGHT, MIN_WORKSPACE_WINDOW_HEIGHT]
5342 );
5343 }
5344
5345 #[test]
5346 fn split_pane_updates_inner_layout_and_focus() {
5347 let mut model = AppModel::new("Main");
5348 let workspace_id = model.active_workspace_id().expect("workspace");
5349 let first_pane = model
5350 .active_workspace()
5351 .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
5352 .expect("pane");
5353
5354 let new_pane = model
5355 .split_pane(workspace_id, Some(first_pane), SplitAxis::Vertical)
5356 .expect("split works");
5357 let workspace = model.workspaces.get(&workspace_id).expect("workspace");
5358 let active_window = workspace.active_window_record().expect("window");
5359 let (_window_id, _window_tab_id, pane_container_id, pane_tab_id) =
5360 workspace.pane_location(new_pane).expect("pane location");
5361
5362 assert_eq!(workspace.active_pane, new_pane);
5363 assert_eq!(
5364 active_window.active_layout().expect("layout").leaves(),
5365 vec![pane_container_id]
5366 );
5367 assert_eq!(
5368 workspace
5369 .pane_containers
5370 .get(&pane_container_id)
5371 .and_then(|pane_container| pane_container.tabs.get(&pane_tab_id))
5372 .expect("pane tab")
5373 .layout
5374 .leaves(),
5375 vec![first_pane, new_pane]
5376 );
5377 }
5378
5379 #[test]
5380 fn split_pane_direction_places_new_pane_on_requested_side() {
5381 let mut model = AppModel::new("Main");
5382 let workspace_id = model.active_workspace_id().expect("workspace");
5383 let first_pane = model
5384 .active_workspace()
5385 .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
5386 .expect("pane");
5387
5388 let left_pane = model
5389 .split_pane_direction(workspace_id, Some(first_pane), Direction::Left)
5390 .expect("split left");
5391 let upper_pane = model
5392 .split_pane_direction(workspace_id, Some(first_pane), Direction::Up)
5393 .expect("split up");
5394
5395 let workspace = model.workspaces.get(&workspace_id).expect("workspace");
5396 let active_window = workspace.active_window_record().expect("window");
5397 let (_window_id, _window_tab_id, pane_container_id, pane_tab_id) =
5398 workspace.pane_location(upper_pane).expect("pane location");
5399
5400 assert_eq!(workspace.active_pane, upper_pane);
5401 assert_eq!(
5402 active_window.active_layout().expect("layout").leaves(),
5403 vec![pane_container_id]
5404 );
5405 assert_eq!(
5406 workspace
5407 .pane_containers
5408 .get(&pane_container_id)
5409 .and_then(|pane_container| pane_container.tabs.get(&pane_tab_id))
5410 .expect("pane tab")
5411 .layout
5412 .leaves(),
5413 vec![left_pane, upper_pane, first_pane]
5414 );
5415 }
5416
5417 #[test]
5418 fn directional_focus_prefers_inner_split_before_neighboring_window() {
5419 let mut model = AppModel::new("Main");
5420 let workspace_id = model.active_workspace_id().expect("workspace");
5421 let first_pane = model
5422 .active_workspace()
5423 .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
5424 .expect("pane");
5425 let split_right_pane = model
5426 .split_pane(workspace_id, Some(first_pane), SplitAxis::Horizontal)
5427 .expect("split");
5428 let right_window_pane = model
5429 .create_workspace_window(workspace_id, Direction::Right)
5430 .expect("window");
5431 model
5432 .focus_pane(workspace_id, first_pane)
5433 .expect("focus first pane");
5434 model
5435 .focus_pane_direction(workspace_id, Direction::Right)
5436 .expect("move right");
5437
5438 assert_eq!(
5439 model
5440 .workspaces
5441 .get(&workspace_id)
5442 .expect("workspace")
5443 .active_pane,
5444 split_right_pane
5445 );
5446
5447 model
5448 .focus_pane_direction(workspace_id, Direction::Right)
5449 .expect("move right again");
5450 assert_eq!(
5451 model
5452 .workspaces
5453 .get(&workspace_id)
5454 .expect("workspace")
5455 .active_pane,
5456 right_window_pane
5457 );
5458
5459 model
5460 .focus_pane_direction(workspace_id, Direction::Left)
5461 .expect("move left");
5462
5463 assert_eq!(
5464 model
5465 .workspaces
5466 .get(&workspace_id)
5467 .expect("workspace")
5468 .active_pane,
5469 split_right_pane
5470 );
5471 }
5472
5473 #[test]
5474 fn closing_last_pane_in_window_removes_window_and_falls_back() {
5475 let mut model = AppModel::new("Main");
5476 let workspace_id = model.active_workspace_id().expect("workspace");
5477 let right_window_pane = model
5478 .create_workspace_window(workspace_id, Direction::Right)
5479 .expect("window");
5480 let lower_window_pane = model
5481 .create_workspace_window(workspace_id, Direction::Down)
5482 .expect("window");
5483
5484 model
5485 .close_pane(workspace_id, lower_window_pane)
5486 .expect("close pane");
5487
5488 let workspace = model.workspaces.get(&workspace_id).expect("workspace");
5489 assert_eq!(workspace.windows.len(), 2);
5490 assert!(!workspace.panes.contains_key(&lower_window_pane));
5491 assert_eq!(workspace.active_pane, right_window_pane);
5492 let right_column = workspace
5493 .columns
5494 .values()
5495 .find(|column| column.window_order.contains(&workspace.active_window))
5496 .expect("right column");
5497 assert_eq!(right_column.window_order.len(), 1);
5498 }
5499
5500 #[test]
5501 fn moving_single_window_column_reorders_columns_and_preserves_width() {
5502 let mut model = AppModel::new("Main");
5503 let workspace_id = model.active_workspace_id().expect("workspace");
5504 let first_window_id = model.active_workspace().expect("workspace").active_window;
5505 let right_window_pane = model
5506 .create_workspace_window(workspace_id, Direction::Right)
5507 .expect("window");
5508 let workspace = model.workspaces.get(&workspace_id).expect("workspace");
5509 let right_window_id = workspace
5510 .window_for_pane(right_window_pane)
5511 .expect("right window id");
5512 let left_column_id = workspace
5513 .column_for_window(first_window_id)
5514 .expect("left column");
5515 let right_column_id = workspace
5516 .column_for_window(right_window_id)
5517 .expect("right column");
5518 let _ = workspace;
5519
5520 model
5521 .set_workspace_column_width(
5522 workspace_id,
5523 right_column_id,
5524 DEFAULT_WORKSPACE_WINDOW_WIDTH + 240,
5525 )
5526 .expect("set width");
5527 model
5528 .move_workspace_window(
5529 workspace_id,
5530 right_window_id,
5531 WorkspaceWindowMoveTarget::ColumnBefore {
5532 column_id: left_column_id,
5533 },
5534 )
5535 .expect("move window");
5536
5537 let workspace = model.workspaces.get(&workspace_id).expect("workspace");
5538 let ordered_columns = workspace.columns.values().collect::<Vec<_>>();
5539 assert_eq!(ordered_columns.len(), 2);
5540 assert_eq!(ordered_columns[0].window_order, vec![right_window_id]);
5541 assert_eq!(
5542 ordered_columns[0].width,
5543 DEFAULT_WORKSPACE_WINDOW_WIDTH + 240
5544 );
5545 assert_eq!(ordered_columns[1].window_order, vec![first_window_id]);
5546 assert_eq!(workspace.active_window, right_window_id);
5547 }
5548
5549 #[test]
5550 fn moving_stacked_window_sideways_creates_a_new_column() {
5551 let mut model = AppModel::new("Main");
5552 let workspace_id = model.active_workspace_id().expect("workspace");
5553 let first_window_id = model.active_workspace().expect("workspace").active_window;
5554 let lower_window_pane = model
5555 .create_workspace_window(workspace_id, Direction::Down)
5556 .expect("window");
5557 let workspace = model.workspaces.get(&workspace_id).expect("workspace");
5558 let lower_window_id = workspace
5559 .window_for_pane(lower_window_pane)
5560 .expect("lower window id");
5561 let source_column_id = workspace
5562 .column_for_window(first_window_id)
5563 .expect("source column");
5564 let _ = workspace;
5565
5566 model
5567 .set_workspace_column_width(
5568 workspace_id,
5569 source_column_id,
5570 DEFAULT_WORKSPACE_WINDOW_WIDTH + 400,
5571 )
5572 .expect("set width");
5573 model
5574 .move_workspace_window(
5575 workspace_id,
5576 lower_window_id,
5577 WorkspaceWindowMoveTarget::ColumnAfter {
5578 column_id: source_column_id,
5579 },
5580 )
5581 .expect("move window");
5582
5583 let workspace = model.workspaces.get(&workspace_id).expect("workspace");
5584 let ordered_columns = workspace.columns.values().collect::<Vec<_>>();
5585 assert_eq!(ordered_columns.len(), 2);
5586 let expected_split_width = (DEFAULT_WORKSPACE_WINDOW_WIDTH + 400) / 2;
5587 assert_eq!(ordered_columns[0].window_order, vec![first_window_id]);
5588 assert_eq!(ordered_columns[0].width, expected_split_width);
5589 assert_eq!(ordered_columns[1].window_order, vec![lower_window_id]);
5590 assert_eq!(ordered_columns[1].width, expected_split_width);
5591 assert_eq!(workspace.active_window, lower_window_id);
5592 }
5593
5594 #[test]
5595 fn moving_window_into_stack_removes_empty_source_column() {
5596 let mut model = AppModel::new("Main");
5597 let workspace_id = model.active_workspace_id().expect("workspace");
5598 let first_window_id = model.active_workspace().expect("workspace").active_window;
5599 let right_window_pane = model
5600 .create_workspace_window(workspace_id, Direction::Right)
5601 .expect("window");
5602 let right_window_id = model
5603 .workspaces
5604 .get(&workspace_id)
5605 .and_then(|workspace| workspace.window_for_pane(right_window_pane))
5606 .expect("right window id");
5607
5608 model
5609 .move_workspace_window(
5610 workspace_id,
5611 right_window_id,
5612 WorkspaceWindowMoveTarget::StackBelow {
5613 window_id: first_window_id,
5614 },
5615 )
5616 .expect("stack window");
5617
5618 let workspace = model.workspaces.get(&workspace_id).expect("workspace");
5619 let only_column = workspace.columns.values().next().expect("single column");
5620 assert_eq!(workspace.columns.len(), 1);
5621 assert_eq!(
5622 only_column.window_order,
5623 vec![first_window_id, right_window_id]
5624 );
5625 assert_eq!(workspace.active_window, right_window_id);
5626 }
5627
5628 #[test]
5629 fn moving_surface_reorders_pane_without_changing_active_surface() {
5630 let mut model = AppModel::new("Main");
5631 let workspace_id = model.active_workspace_id().expect("workspace");
5632 let pane_id = model.active_workspace().expect("workspace").active_pane;
5633 let first_surface_id = model
5634 .active_workspace()
5635 .and_then(|workspace| workspace.panes.get(&pane_id))
5636 .map(|pane| pane.active_surface)
5637 .expect("surface id");
5638
5639 let second_surface_id = model
5640 .create_surface(workspace_id, pane_id, PaneKind::Terminal)
5641 .expect("second surface");
5642 let third_surface_id = model
5643 .create_surface(workspace_id, pane_id, PaneKind::Terminal)
5644 .expect("third surface");
5645
5646 model
5647 .focus_surface(workspace_id, pane_id, second_surface_id)
5648 .expect("focus second surface");
5649 model
5650 .move_surface(workspace_id, pane_id, second_surface_id, 0)
5651 .expect("move second surface to front");
5652
5653 let pane = model
5654 .active_workspace()
5655 .and_then(|workspace| workspace.panes.get(&pane_id))
5656 .expect("pane");
5657 let order = pane.surface_ids().collect::<Vec<_>>();
5658
5659 assert_eq!(
5660 order,
5661 vec![second_surface_id, first_surface_id, third_surface_id]
5662 );
5663 assert_eq!(pane.active_surface, second_surface_id);
5664 }
5665
5666 #[test]
5667 fn moving_surface_clamps_to_end_of_pane() {
5668 let mut model = AppModel::new("Main");
5669 let workspace_id = model.active_workspace_id().expect("workspace");
5670 let pane_id = model.active_workspace().expect("workspace").active_pane;
5671 let first_surface_id = model
5672 .active_workspace()
5673 .and_then(|workspace| workspace.panes.get(&pane_id))
5674 .map(|pane| pane.active_surface)
5675 .expect("surface id");
5676 let second_surface_id = model
5677 .create_surface(workspace_id, pane_id, PaneKind::Terminal)
5678 .expect("second surface");
5679 let third_surface_id = model
5680 .create_surface(workspace_id, pane_id, PaneKind::Terminal)
5681 .expect("third surface");
5682
5683 model
5684 .move_surface(workspace_id, pane_id, first_surface_id, usize::MAX)
5685 .expect("move first surface to end");
5686
5687 let pane = model
5688 .active_workspace()
5689 .and_then(|workspace| workspace.panes.get(&pane_id))
5690 .expect("pane");
5691 let order = pane.surface_ids().collect::<Vec<_>>();
5692
5693 assert_eq!(
5694 order,
5695 vec![second_surface_id, third_surface_id, first_surface_id]
5696 );
5697 }
5698
5699 #[test]
5700 fn moving_surface_to_current_index_is_a_noop() {
5701 let mut model = AppModel::new("Main");
5702 let workspace_id = model.active_workspace_id().expect("workspace");
5703 let pane_id = model.active_workspace().expect("workspace").active_pane;
5704 let first_surface_id = model
5705 .active_workspace()
5706 .and_then(|workspace| workspace.panes.get(&pane_id))
5707 .map(|pane| pane.active_surface)
5708 .expect("surface id");
5709 let second_surface_id = model
5710 .create_surface(workspace_id, pane_id, PaneKind::Terminal)
5711 .expect("second surface");
5712
5713 model
5714 .move_surface(workspace_id, pane_id, second_surface_id, 1)
5715 .expect("move second surface to current slot");
5716
5717 let pane = model
5718 .active_workspace()
5719 .and_then(|workspace| workspace.panes.get(&pane_id))
5720 .expect("pane");
5721 let order = pane.surface_ids().collect::<Vec<_>>();
5722
5723 assert_eq!(order, vec![first_surface_id, second_surface_id]);
5724 assert_eq!(pane.active_surface, second_surface_id);
5725 }
5726
5727 #[test]
5728 fn transferring_surface_to_another_pane_focuses_target_pane() {
5729 let mut model = AppModel::new("Main");
5730 let workspace_id = model.active_workspace_id().expect("workspace");
5731 let source_pane_id = model.active_workspace().expect("workspace").active_pane;
5732 let target_pane_id = model
5733 .split_pane(workspace_id, Some(source_pane_id), SplitAxis::Horizontal)
5734 .expect("split");
5735 let target_placeholder_id = model
5736 .active_workspace()
5737 .and_then(|workspace| workspace.panes.get(&target_pane_id))
5738 .and_then(|pane| pane.surface_ids().next())
5739 .expect("placeholder");
5740
5741 let first_surface_id = model
5742 .active_workspace()
5743 .and_then(|workspace| workspace.panes.get(&source_pane_id))
5744 .and_then(|pane| pane.surface_ids().next())
5745 .expect("first surface");
5746 let second_surface_id = model
5747 .create_surface(workspace_id, source_pane_id, PaneKind::Browser)
5748 .expect("second surface");
5749
5750 model
5751 .transfer_surface(
5752 workspace_id,
5753 source_pane_id,
5754 second_surface_id,
5755 workspace_id,
5756 target_pane_id,
5757 0,
5758 )
5759 .expect("transfer");
5760
5761 let workspace = model.active_workspace().expect("workspace");
5762 let source_order = workspace
5763 .panes
5764 .get(&source_pane_id)
5765 .expect("source pane")
5766 .surface_ids()
5767 .collect::<Vec<_>>();
5768 let target_pane = workspace.panes.get(&target_pane_id).expect("target pane");
5769 let target_order = target_pane.surface_ids().collect::<Vec<_>>();
5770
5771 assert_eq!(source_order, vec![first_surface_id]);
5772 assert_eq!(target_order, vec![second_surface_id, target_placeholder_id]);
5773 assert_eq!(target_pane.active_surface, second_surface_id);
5774 assert_eq!(workspace.active_pane, target_pane_id);
5775 }
5776
5777 #[test]
5778 fn moving_surface_to_split_from_same_pane_creates_neighbor_pane() {
5779 let mut model = AppModel::new("Main");
5780 let workspace_id = model.active_workspace_id().expect("workspace");
5781 let source_pane_id = model.active_workspace().expect("workspace").active_pane;
5782 let first_surface_id = model
5783 .active_workspace()
5784 .and_then(|workspace| workspace.panes.get(&source_pane_id))
5785 .map(|pane| pane.active_surface)
5786 .expect("first surface");
5787 let moved_surface_id = model
5788 .create_surface(workspace_id, source_pane_id, PaneKind::Browser)
5789 .expect("second surface");
5790
5791 let new_pane_id = model
5792 .move_surface_to_split(
5793 workspace_id,
5794 source_pane_id,
5795 moved_surface_id,
5796 workspace_id,
5797 source_pane_id,
5798 Direction::Right,
5799 )
5800 .expect("move to split");
5801
5802 let workspace = model.active_workspace().expect("workspace");
5803 let window = workspace.active_window_record().expect("window");
5804 let source_pane = workspace.panes.get(&source_pane_id).expect("source pane");
5805 let target_pane = workspace.panes.get(&new_pane_id).expect("new pane");
5806 let (_window_id, _window_tab_id, pane_container_id, pane_tab_id) =
5807 workspace.pane_location(new_pane_id).expect("pane location");
5808
5809 assert_eq!(
5810 window.active_layout().expect("layout").leaves(),
5811 vec![pane_container_id]
5812 );
5813 assert_eq!(
5814 workspace
5815 .pane_containers
5816 .get(&pane_container_id)
5817 .and_then(|pane_container| pane_container.tabs.get(&pane_tab_id))
5818 .expect("pane tab")
5819 .layout
5820 .leaves(),
5821 vec![source_pane_id, new_pane_id]
5822 );
5823 assert_eq!(
5824 source_pane.surface_ids().collect::<Vec<_>>(),
5825 vec![first_surface_id]
5826 );
5827 assert_eq!(
5828 target_pane.surface_ids().collect::<Vec<_>>(),
5829 vec![moved_surface_id]
5830 );
5831 assert_eq!(workspace.active_pane, new_pane_id);
5832 assert_eq!(target_pane.active_surface, moved_surface_id);
5833 }
5834
5835 #[test]
5836 fn moving_surface_to_split_across_windows_closes_empty_source_window() {
5837 let mut model = AppModel::new("Main");
5838 let workspace_id = model.active_workspace_id().expect("workspace");
5839 let source_pane_id = model.active_workspace().expect("workspace").active_pane;
5840 let target_pane_id = model
5841 .create_workspace_window(workspace_id, Direction::Right)
5842 .expect("window");
5843 let target_window_id = model
5844 .workspaces
5845 .get(&workspace_id)
5846 .and_then(|workspace| workspace.window_for_pane(target_pane_id))
5847 .expect("target window");
5848 let moved_surface_id = model
5849 .active_workspace()
5850 .and_then(|workspace| workspace.panes.get(&source_pane_id))
5851 .map(|pane| pane.active_surface)
5852 .expect("surface");
5853
5854 let new_pane_id = model
5855 .move_surface_to_split(
5856 workspace_id,
5857 source_pane_id,
5858 moved_surface_id,
5859 workspace_id,
5860 target_pane_id,
5861 Direction::Left,
5862 )
5863 .expect("move to split");
5864
5865 let workspace = model.active_workspace().expect("workspace");
5866 let target_window = workspace.windows.get(&target_window_id).expect("window");
5867 let (_window_id, _window_tab_id, pane_container_id, pane_tab_id) =
5868 workspace.pane_location(new_pane_id).expect("pane location");
5869
5870 assert_eq!(workspace.windows.len(), 1);
5871 assert!(!workspace.panes.contains_key(&source_pane_id));
5872 assert_eq!(workspace.active_window, target_window_id);
5873 assert_eq!(workspace.active_pane, new_pane_id);
5874 assert_eq!(
5875 target_window.active_layout().expect("layout").leaves(),
5876 vec![pane_container_id]
5877 );
5878 assert_eq!(
5879 workspace
5880 .pane_containers
5881 .get(&pane_container_id)
5882 .and_then(|pane_container| pane_container.tabs.get(&pane_tab_id))
5883 .expect("pane tab")
5884 .layout
5885 .leaves(),
5886 vec![new_pane_id, target_pane_id]
5887 );
5888 assert_eq!(
5889 workspace
5890 .panes
5891 .get(&new_pane_id)
5892 .expect("new pane")
5893 .surface_ids()
5894 .collect::<Vec<_>>(),
5895 vec![moved_surface_id]
5896 );
5897 }
5898
5899 #[test]
5900 fn moving_only_surface_to_split_from_same_pane_is_rejected() {
5901 let mut model = AppModel::new("Main");
5902 let workspace_id = model.active_workspace_id().expect("workspace");
5903 let pane_id = model.active_workspace().expect("workspace").active_pane;
5904 let surface_id = model
5905 .active_workspace()
5906 .and_then(|workspace| workspace.panes.get(&pane_id))
5907 .map(|pane| pane.active_surface)
5908 .expect("surface");
5909
5910 let error = model
5911 .move_surface_to_split(
5912 workspace_id,
5913 pane_id,
5914 surface_id,
5915 workspace_id,
5916 pane_id,
5917 Direction::Right,
5918 )
5919 .expect_err("reject self split of only surface");
5920
5921 assert!(matches!(
5922 error,
5923 DomainError::InvalidOperation("cannot split a pane from its only surface")
5924 ));
5925 }
5926
5927 #[test]
5928 fn moving_surface_to_another_workspace_creates_new_window_and_switches_workspace() {
5929 let mut model = AppModel::new("Main");
5930 let source_workspace_id = model.active_workspace_id().expect("workspace");
5931 let source_pane_id = model.active_workspace().expect("workspace").active_pane;
5932 let first_surface_id = model
5933 .active_workspace()
5934 .and_then(|workspace| workspace.panes.get(&source_pane_id))
5935 .map(|pane| pane.active_surface)
5936 .expect("first surface");
5937 let moved_surface_id = model
5938 .create_surface(source_workspace_id, source_pane_id, PaneKind::Browser)
5939 .expect("second surface");
5940 let target_workspace_id = model.create_workspace("Secondary");
5941
5942 let new_pane_id = model
5943 .move_surface_to_workspace(
5944 source_workspace_id,
5945 source_pane_id,
5946 moved_surface_id,
5947 target_workspace_id,
5948 )
5949 .expect("move to workspace");
5950
5951 let source_workspace = model
5952 .workspaces
5953 .get(&source_workspace_id)
5954 .expect("source workspace");
5955 let target_workspace = model
5956 .workspaces
5957 .get(&target_workspace_id)
5958 .expect("target workspace");
5959 let moved_window_id = target_workspace
5960 .window_for_pane(new_pane_id)
5961 .expect("moved window");
5962
5963 assert_eq!(model.active_workspace_id(), Some(target_workspace_id));
5964 assert_eq!(
5965 source_workspace
5966 .panes
5967 .get(&source_pane_id)
5968 .expect("source pane")
5969 .surface_ids()
5970 .collect::<Vec<_>>(),
5971 vec![first_surface_id]
5972 );
5973 assert_eq!(
5974 target_workspace
5975 .panes
5976 .get(&new_pane_id)
5977 .expect("new pane")
5978 .surface_ids()
5979 .collect::<Vec<_>>(),
5980 vec![moved_surface_id]
5981 );
5982 assert_eq!(target_workspace.active_window, moved_window_id);
5983 assert_eq!(target_workspace.active_pane, new_pane_id);
5984 }
5985
5986 #[test]
5987 fn transferring_surface_to_existing_pane_in_another_workspace_moves_notifications_and_flash() {
5988 let mut model = AppModel::new("Main");
5989 let source_workspace_id = model.active_workspace_id().expect("workspace");
5990 let source_pane_id = model.active_workspace().expect("workspace").active_pane;
5991 let _first_surface_id = model
5992 .active_workspace()
5993 .and_then(|workspace| workspace.panes.get(&source_pane_id))
5994 .map(|pane| pane.active_surface)
5995 .expect("first surface");
5996 let moved_surface_id = model
5997 .create_surface(source_workspace_id, source_pane_id, PaneKind::Browser)
5998 .expect("second surface");
5999 model
6000 .create_agent_notification(
6001 AgentTarget::Surface {
6002 workspace_id: source_workspace_id,
6003 pane_id: source_pane_id,
6004 surface_id: moved_surface_id,
6005 },
6006 SignalKind::Notification,
6007 Some("Needs review".into()),
6008 None,
6009 Some("review-1".into()),
6010 "Check this browser tab".into(),
6011 AttentionState::WaitingInput,
6012 )
6013 .expect("notification");
6014 model
6015 .trigger_surface_flash(source_workspace_id, source_pane_id, moved_surface_id)
6016 .expect("flash");
6017
6018 let target_workspace_id = model.create_workspace("Secondary");
6019 let target_pane_id = model.active_workspace().expect("workspace").active_pane;
6020
6021 model
6022 .transfer_surface(
6023 source_workspace_id,
6024 source_pane_id,
6025 moved_surface_id,
6026 target_workspace_id,
6027 target_pane_id,
6028 usize::MAX,
6029 )
6030 .expect("transfer");
6031
6032 let source_workspace = model
6033 .workspaces
6034 .get(&source_workspace_id)
6035 .expect("source workspace");
6036 let target_workspace = model
6037 .workspaces
6038 .get(&target_workspace_id)
6039 .expect("target workspace");
6040 let target_pane = target_workspace
6041 .panes
6042 .get(&target_pane_id)
6043 .expect("target pane");
6044
6045 assert_eq!(model.active_workspace_id(), Some(target_workspace_id));
6046 assert!(
6047 !source_workspace
6048 .notifications
6049 .iter()
6050 .any(|notification| notification.surface_id == moved_surface_id)
6051 );
6052 assert!(
6053 source_workspace
6054 .surface_flash_tokens
6055 .get(&moved_surface_id)
6056 .is_none()
6057 );
6058 assert!(
6059 target_pane
6060 .surface_ids()
6061 .collect::<Vec<_>>()
6062 .contains(&moved_surface_id)
6063 );
6064 assert_eq!(target_pane.active_surface, moved_surface_id);
6065 assert!(target_workspace.notifications.iter().any(|notification| {
6066 notification.surface_id == moved_surface_id && notification.pane_id == target_pane_id
6067 }));
6068 assert!(
6069 target_workspace
6070 .surface_flash_tokens
6071 .get(&moved_surface_id)
6072 .is_some()
6073 );
6074 }
6075
6076 #[test]
6077 fn transferring_active_surface_normalizes_the_source_pane() {
6078 let mut model = AppModel::new("Main");
6079 let workspace_id = model.active_workspace_id().expect("workspace");
6080 let source_pane_id = model.active_workspace().expect("workspace").active_pane;
6081 let remaining_surface_id = model
6082 .active_workspace()
6083 .and_then(|workspace| workspace.panes.get(&source_pane_id))
6084 .map(|pane| pane.active_surface)
6085 .expect("remaining surface");
6086 let moved_surface_id = model
6087 .create_surface(workspace_id, source_pane_id, PaneKind::Browser)
6088 .expect("second surface");
6089 let target_pane_id = model
6090 .split_pane(workspace_id, Some(source_pane_id), SplitAxis::Horizontal)
6091 .expect("split pane");
6092
6093 model
6094 .transfer_surface(
6095 workspace_id,
6096 source_pane_id,
6097 moved_surface_id,
6098 workspace_id,
6099 target_pane_id,
6100 usize::MAX,
6101 )
6102 .expect("transfer");
6103
6104 let workspace = model.workspaces.get(&workspace_id).expect("workspace");
6105 let source_pane = workspace.panes.get(&source_pane_id).expect("source pane");
6106
6107 assert_eq!(source_pane.active_surface, remaining_surface_id);
6108 assert_eq!(
6109 source_pane.active_surface().map(|surface| surface.id),
6110 Some(remaining_surface_id)
6111 );
6112 assert_eq!(
6113 source_pane.surface_ids().collect::<Vec<_>>(),
6114 vec![remaining_surface_id]
6115 );
6116 }
6117
6118 #[test]
6119 fn moving_surface_to_split_in_another_workspace_closes_empty_source_pane() {
6120 let mut model = AppModel::new("Main");
6121 let source_workspace_id = model.active_workspace_id().expect("workspace");
6122 let source_pane_id = model.active_workspace().expect("workspace").active_pane;
6123 let anchor_pane_id = model
6124 .split_pane(
6125 source_workspace_id,
6126 Some(source_pane_id),
6127 SplitAxis::Horizontal,
6128 )
6129 .expect("split source workspace");
6130 model
6131 .focus_pane(source_workspace_id, source_pane_id)
6132 .expect("focus source pane");
6133 let moved_surface_id = model
6134 .active_workspace()
6135 .and_then(|workspace| workspace.panes.get(&source_pane_id))
6136 .map(|pane| pane.active_surface)
6137 .expect("moved surface");
6138
6139 let target_workspace_id = model.create_workspace("Secondary");
6140 let target_pane_id = model.active_workspace().expect("workspace").active_pane;
6141
6142 let new_pane_id = model
6143 .move_surface_to_split(
6144 source_workspace_id,
6145 source_pane_id,
6146 moved_surface_id,
6147 target_workspace_id,
6148 target_pane_id,
6149 Direction::Left,
6150 )
6151 .expect("move to split");
6152
6153 let source_workspace = model
6154 .workspaces
6155 .get(&source_workspace_id)
6156 .expect("source workspace");
6157 let target_workspace = model
6158 .workspaces
6159 .get(&target_workspace_id)
6160 .expect("target workspace");
6161 let target_window_id = target_workspace
6162 .window_for_pane(target_pane_id)
6163 .expect("target window");
6164 let target_window = target_workspace
6165 .windows
6166 .get(&target_window_id)
6167 .expect("target window record");
6168 let (_window_id, _window_tab_id, pane_container_id, pane_tab_id) = target_workspace
6169 .pane_location(new_pane_id)
6170 .expect("pane location");
6171
6172 assert_eq!(model.active_workspace_id(), Some(target_workspace_id));
6173 assert!(!source_workspace.panes.contains_key(&source_pane_id));
6174 assert!(source_workspace.panes.contains_key(&anchor_pane_id));
6175 assert_eq!(
6176 target_window.active_layout().expect("layout").leaves(),
6177 vec![pane_container_id]
6178 );
6179 assert_eq!(
6180 target_workspace
6181 .pane_containers
6182 .get(&pane_container_id)
6183 .and_then(|pane_container| pane_container.tabs.get(&pane_tab_id))
6184 .expect("pane tab")
6185 .layout
6186 .leaves(),
6187 vec![new_pane_id, target_pane_id]
6188 );
6189 assert_eq!(target_workspace.active_pane, new_pane_id);
6190 assert_eq!(
6191 target_workspace
6192 .panes
6193 .get(&new_pane_id)
6194 .expect("new pane")
6195 .surface_ids()
6196 .collect::<Vec<_>>(),
6197 vec![moved_surface_id]
6198 );
6199 }
6200
6201 #[test]
6202 fn transferring_last_surface_closes_the_source_pane() {
6203 let mut model = AppModel::new("Main");
6204 let workspace_id = model.active_workspace_id().expect("workspace");
6205 let source_pane_id = model.active_workspace().expect("workspace").active_pane;
6206 let target_pane_id = model
6207 .split_pane(workspace_id, Some(source_pane_id), SplitAxis::Horizontal)
6208 .expect("split");
6209 let moved_surface_id = model
6210 .active_workspace()
6211 .and_then(|workspace| workspace.panes.get(&source_pane_id))
6212 .and_then(|pane| pane.surface_ids().next())
6213 .expect("surface");
6214
6215 model
6216 .transfer_surface(
6217 workspace_id,
6218 source_pane_id,
6219 moved_surface_id,
6220 workspace_id,
6221 target_pane_id,
6222 usize::MAX,
6223 )
6224 .expect("transfer");
6225
6226 let workspace = model.active_workspace().expect("workspace");
6227 assert!(!workspace.panes.contains_key(&source_pane_id));
6228 let target_order = workspace
6229 .panes
6230 .get(&target_pane_id)
6231 .expect("target pane")
6232 .surface_ids()
6233 .collect::<Vec<_>>();
6234 assert!(target_order.contains(&moved_surface_id));
6235 assert_eq!(workspace.active_pane, target_pane_id);
6236 }
6237
6238 #[test]
6239 fn closing_surface_after_reorder_removes_the_requested_surface() {
6240 let mut model = AppModel::new("Main");
6241 let workspace_id = model.active_workspace_id().expect("workspace");
6242 let pane_id = model.active_workspace().expect("workspace").active_pane;
6243 let first_surface_id = model
6244 .active_workspace()
6245 .and_then(|workspace| workspace.panes.get(&pane_id))
6246 .map(|pane| pane.active_surface)
6247 .expect("surface id");
6248 let second_surface_id = model
6249 .create_surface(workspace_id, pane_id, PaneKind::Terminal)
6250 .expect("second surface");
6251 let third_surface_id = model
6252 .create_surface(workspace_id, pane_id, PaneKind::Terminal)
6253 .expect("third surface");
6254
6255 model
6256 .move_surface(workspace_id, pane_id, first_surface_id, 2)
6257 .expect("move first surface to end");
6258 model
6259 .close_surface(workspace_id, pane_id, second_surface_id)
6260 .expect("close second surface");
6261
6262 let pane = model
6263 .active_workspace()
6264 .and_then(|workspace| workspace.panes.get(&pane_id))
6265 .expect("pane");
6266 let order = pane.surface_ids().collect::<Vec<_>>();
6267
6268 assert_eq!(order, vec![third_surface_id, first_surface_id]);
6269 assert!(!order.contains(&second_surface_id));
6270 }
6271
6272 #[test]
6273 fn resizing_window_and_split_updates_state() {
6274 let mut model = AppModel::new("Main");
6275 let workspace_id = model.active_workspace_id().expect("workspace");
6276 let first_pane = model
6277 .active_workspace()
6278 .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
6279 .expect("pane");
6280 let second_pane = model
6281 .split_pane(workspace_id, Some(first_pane), SplitAxis::Horizontal)
6282 .expect("split");
6283
6284 model
6285 .focus_pane(workspace_id, second_pane)
6286 .expect("focus second pane");
6287 model
6288 .resize_active_pane_split(workspace_id, Direction::Right, 60)
6289 .expect("resize split");
6290 model
6291 .resize_active_window(workspace_id, Direction::Right, 120)
6292 .expect("resize window");
6293 model
6294 .resize_active_window(workspace_id, Direction::Down, 90)
6295 .expect("resize height");
6296
6297 let workspace = model.workspaces.get(&workspace_id).expect("workspace");
6298 let window = workspace.active_window_record().expect("window");
6299 let column = workspace
6300 .active_column_id()
6301 .and_then(|column_id| workspace.columns.get(&column_id))
6302 .expect("column");
6303 let (_window_id, _window_tab_id, pane_container_id, pane_tab_id) =
6304 workspace.pane_location(second_pane).expect("pane location");
6305 let pane_tab = workspace
6306 .pane_containers
6307 .get(&pane_container_id)
6308 .and_then(|pane_container| pane_container.tabs.get(&pane_tab_id))
6309 .expect("pane tab");
6310 let PaneTabLayoutNode::Split { ratio, .. } = &pane_tab.layout else {
6311 panic!("expected split layout");
6312 };
6313 assert_eq!(*ratio, 440);
6314 assert_eq!(column.width, DEFAULT_WORKSPACE_WINDOW_WIDTH + 120);
6315 assert_eq!(window.height, DEFAULT_WORKSPACE_WINDOW_HEIGHT + 90);
6316 }
6317
6318 #[test]
6319 fn setting_pane_tab_split_ratio_updates_target_split() {
6320 let mut model = AppModel::new("Main");
6321 let workspace_id = model.active_workspace_id().expect("workspace");
6322 let first_pane = model
6323 .active_workspace()
6324 .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
6325 .expect("pane");
6326 let second_pane = model
6327 .split_pane(workspace_id, Some(first_pane), SplitAxis::Horizontal)
6328 .expect("split");
6329
6330 let (pane_container_id, pane_tab_id) = {
6331 let workspace = model.workspaces.get(&workspace_id).expect("workspace");
6332 let (_window_id, _window_tab_id, pane_container_id, pane_tab_id) =
6333 workspace.pane_location(second_pane).expect("pane location");
6334 (pane_container_id, pane_tab_id)
6335 };
6336
6337 model
6338 .set_pane_tab_split_ratio(workspace_id, pane_container_id, pane_tab_id, &[], 700)
6339 .expect("set split ratio");
6340
6341 let workspace = model.workspaces.get(&workspace_id).expect("workspace");
6342 let pane_tab = workspace
6343 .pane_containers
6344 .get(&pane_container_id)
6345 .and_then(|pane_container| pane_container.tabs.get(&pane_tab_id))
6346 .expect("pane tab");
6347 let PaneTabLayoutNode::Split { ratio, .. } = &pane_tab.layout else {
6348 panic!("expected split layout");
6349 };
6350 assert_eq!(*ratio, 700);
6351 }
6352
6353 #[test]
6354 fn clean_break_rejects_legacy_workspace_layouts() {
6355 let workspace_id = WorkspaceId::new();
6356 let window_id = WindowId::new();
6357 let left_pane = PaneRecord::new(PaneKind::Terminal);
6358 let right_pane = PaneRecord::new(PaneKind::Terminal);
6359
6360 let encoded = json!({
6361 "schema_version": 1,
6362 "captured_at": OffsetDateTime::now_utc(),
6363 "model": {
6364 "active_window": window_id,
6365 "windows": {
6366 window_id.to_string(): {
6367 "id": window_id,
6368 "workspace_order": [workspace_id],
6369 "active_workspace": workspace_id
6370 }
6371 },
6372 "workspaces": {
6373 workspace_id.to_string(): {
6374 "id": workspace_id,
6375 "label": "Main",
6376 "layout": {
6377 "kind": "scrollable_tiling",
6378 "columns": [
6379 {"panes": [left_pane.id]},
6380 {"panes": [right_pane.id]}
6381 ],
6382 "viewport": {"x": 64, "y": 24}
6383 },
6384 "panes": {
6385 left_pane.id.to_string(): left_pane,
6386 right_pane.id.to_string(): right_pane
6387 },
6388 "active_pane": right_pane.id,
6389 "notifications": []
6390 }
6391 }
6392 }
6393 });
6394
6395 let decoded = serde_json::from_value::<PersistedSession>(encoded);
6396 assert!(decoded.is_err());
6397 }
6398
6399 #[test]
6400 fn signals_flow_into_activity_and_summary() {
6401 let mut model = AppModel::new("Main");
6402 let workspace_id = model.active_workspace_id().expect("workspace");
6403 let pane_id = model
6404 .active_workspace()
6405 .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
6406 .expect("pane");
6407
6408 model
6409 .apply_signal(
6410 workspace_id,
6411 pane_id,
6412 SignalEvent::new(
6413 "test",
6414 SignalKind::WaitingInput,
6415 Some("Need approval".into()),
6416 ),
6417 )
6418 .expect("signal applied");
6419
6420 let summaries = model
6421 .workspace_summaries(model.active_window)
6422 .expect("summary available");
6423 let summary = summaries.first().expect("summary");
6424
6425 assert_eq!(summary.highest_attention, AttentionState::WaitingInput);
6426 assert_eq!(
6427 summary
6428 .counts_by_attention
6429 .get(&AttentionState::WaitingInput)
6430 .copied(),
6431 Some(1)
6432 );
6433 assert_eq!(model.activity_items().len(), 1);
6434 }
6435
6436 #[test]
6437 fn persisted_session_roundtrips() {
6438 let model = AppModel::demo();
6439 let snapshot = model.snapshot();
6440 let encoded = serde_json::to_string_pretty(&snapshot).expect("serialize");
6441 let decoded: PersistedSession = serde_json::from_str(&encoded).expect("deserialize");
6442
6443 assert_eq!(decoded.schema_version, SESSION_SCHEMA_VERSION);
6444 assert_eq!(decoded.model.workspaces.len(), model.workspaces.len());
6445 }
6446
6447 #[test]
6448 fn notification_signal_maps_to_waiting_attention() {
6449 let mut model = AppModel::new("Main");
6450 let workspace_id = model.active_workspace_id().expect("workspace");
6451 let pane_id = model.active_workspace().expect("workspace").active_pane;
6452
6453 model
6454 .apply_signal(
6455 workspace_id,
6456 pane_id,
6457 SignalEvent::new(
6458 "notify:Codex",
6459 SignalKind::Notification,
6460 Some("Turn complete".into()),
6461 ),
6462 )
6463 .expect("signal applied");
6464
6465 let surface = model
6466 .active_workspace()
6467 .and_then(|workspace| workspace.panes.get(&pane_id))
6468 .and_then(PaneRecord::active_surface)
6469 .expect("surface");
6470 assert_eq!(surface.attention, AttentionState::WaitingInput);
6471 }
6472
6473 #[test]
6474 fn agent_hook_notification_updates_context_without_creating_attention_item() {
6475 let mut model = AppModel::new("Main");
6476 let workspace_id = model.active_workspace_id().expect("workspace");
6477 let pane_id = model.active_workspace().expect("workspace").active_pane;
6478
6479 model
6480 .apply_signal(
6481 workspace_id,
6482 pane_id,
6483 SignalEvent::with_metadata(
6484 "agent-hook:codex",
6485 SignalKind::Notification,
6486 Some("Turn complete".into()),
6487 Some(SignalPaneMetadata {
6488 title: None,
6489 agent_title: Some("Codex".into()),
6490 cwd: None,
6491 repo_name: None,
6492 git_branch: None,
6493 ports: Vec::new(),
6494 agent_kind: Some("codex".into()),
6495 agent_active: Some(true),
6496 agent_command: None,
6497 }),
6498 ),
6499 )
6500 .expect("notification applied");
6501
6502 let workspace = model.active_workspace().expect("workspace");
6503 let surface = workspace
6504 .panes
6505 .get(&pane_id)
6506 .and_then(PaneRecord::active_surface)
6507 .expect("surface");
6508 assert_eq!(surface.attention, AttentionState::WaitingInput);
6509 assert_eq!(
6510 surface.metadata.latest_agent_message.as_deref(),
6511 Some("Turn complete")
6512 );
6513 assert_eq!(
6514 surface.metadata.agent_state,
6515 Some(WorkspaceAgentState::Waiting)
6516 );
6517 assert!(workspace.notifications.is_empty());
6518 }
6519
6520 #[test]
6521 fn stop_signal_uses_cached_agent_message_for_final_notification() {
6522 let mut model = AppModel::new("Main");
6523 let workspace_id = model.active_workspace_id().expect("workspace");
6524 let pane_id = model.active_workspace().expect("workspace").active_pane;
6525
6526 model
6527 .apply_signal(
6528 workspace_id,
6529 pane_id,
6530 SignalEvent::with_metadata(
6531 "agent-hook:codex",
6532 SignalKind::Notification,
6533 Some("Turn complete".into()),
6534 Some(SignalPaneMetadata {
6535 title: None,
6536 agent_title: Some("Codex".into()),
6537 cwd: None,
6538 repo_name: None,
6539 git_branch: None,
6540 ports: Vec::new(),
6541 agent_kind: Some("codex".into()),
6542 agent_active: Some(true),
6543 agent_command: None,
6544 }),
6545 ),
6546 )
6547 .expect("notification applied");
6548
6549 model
6550 .apply_signal(
6551 workspace_id,
6552 pane_id,
6553 SignalEvent::with_metadata(
6554 "agent-hook:codex",
6555 SignalKind::Completed,
6556 None,
6557 Some(SignalPaneMetadata {
6558 title: None,
6559 agent_title: Some("Codex".into()),
6560 cwd: None,
6561 repo_name: None,
6562 git_branch: None,
6563 ports: Vec::new(),
6564 agent_kind: Some("codex".into()),
6565 agent_active: Some(false),
6566 agent_command: None,
6567 }),
6568 ),
6569 )
6570 .expect("completed applied");
6571
6572 let workspace = model.active_workspace().expect("workspace");
6573 let notification = workspace
6574 .notifications
6575 .last()
6576 .expect("completion notification");
6577 assert_eq!(notification.kind, SignalKind::Completed);
6578 assert_eq!(notification.state, AttentionState::Completed);
6579 assert_eq!(notification.message, "Turn complete");
6580 assert_eq!(notification.title.as_deref(), Some("Codex"));
6581
6582 let surface = workspace
6583 .panes
6584 .get(&pane_id)
6585 .and_then(PaneRecord::active_surface)
6586 .expect("surface");
6587 let session = surface
6588 .agent_session
6589 .as_ref()
6590 .expect("completed signal should preserve recent agent session");
6591 assert_eq!(
6592 surface.metadata.agent_state,
6593 Some(WorkspaceAgentState::Completed)
6594 );
6595 assert!(!surface.metadata.agent_active);
6596 assert_eq!(session.state, WorkspaceAgentState::Completed);
6597 assert_eq!(session.latest_message.as_deref(), Some("Turn complete"));
6598 }
6599
6600 #[test]
6601 fn progress_signals_update_attention_without_creating_activity_items() {
6602 let mut model = AppModel::new("Main");
6603 let workspace_id = model.active_workspace_id().expect("workspace");
6604 let pane_id = model.active_workspace().expect("workspace").active_pane;
6605
6606 model
6607 .update_pane_metadata(
6608 pane_id,
6609 PaneMetadataPatch {
6610 title: Some("Codex".into()),
6611 cwd: None,
6612 url: None,
6613 browser_profile_mode: None,
6614 repo_name: None,
6615 git_branch: None,
6616 ports: None,
6617 agent_kind: Some("codex".into()),
6618 },
6619 )
6620 .expect("metadata updated");
6621 model
6622 .apply_signal(
6623 workspace_id,
6624 pane_id,
6625 SignalEvent::new("test", SignalKind::Progress, Some("Still working".into())),
6626 )
6627 .expect("signal applied");
6628
6629 let surface = model
6630 .active_workspace()
6631 .and_then(|workspace| workspace.panes.get(&pane_id))
6632 .and_then(PaneRecord::active_surface)
6633 .expect("surface");
6634
6635 assert_eq!(surface.attention, AttentionState::Busy);
6636 assert!(model.activity_items().is_empty());
6637 }
6638
6639 #[test]
6640 fn started_signals_do_not_create_attention_items() {
6641 let mut model = AppModel::new("Main");
6642 let workspace_id = model.active_workspace_id().expect("workspace");
6643 let pane_id = model.active_workspace().expect("workspace").active_pane;
6644
6645 model
6646 .apply_signal(
6647 workspace_id,
6648 pane_id,
6649 SignalEvent::with_metadata(
6650 "agent-hook:codex",
6651 SignalKind::Started,
6652 None,
6653 Some(SignalPaneMetadata {
6654 title: None,
6655 agent_title: Some("Codex".into()),
6656 cwd: None,
6657 repo_name: None,
6658 git_branch: None,
6659 ports: Vec::new(),
6660 agent_kind: Some("codex".into()),
6661 agent_active: Some(true),
6662 agent_command: None,
6663 }),
6664 ),
6665 )
6666 .expect("started applied");
6667
6668 let surface = model
6669 .active_workspace()
6670 .and_then(|workspace| workspace.panes.get(&pane_id))
6671 .and_then(PaneRecord::active_surface)
6672 .expect("surface");
6673
6674 assert_eq!(surface.attention, AttentionState::Busy);
6675 assert!(model.activity_items().is_empty());
6676 }
6677
6678 #[test]
6679 fn metadata_signals_for_shell_prompt_clear_stale_agent_identity() {
6680 let mut model = AppModel::new("Main");
6681 let workspace_id = model.active_workspace_id().expect("workspace");
6682 let pane_id = model.active_workspace().expect("workspace").active_pane;
6683 let stale_timestamp = OffsetDateTime::now_utc() - Duration::minutes(20);
6684
6685 model
6686 .apply_signal(
6687 workspace_id,
6688 pane_id,
6689 SignalEvent {
6690 source: "test".into(),
6691 kind: SignalKind::Completed,
6692 message: Some("Done".into()),
6693 metadata: Some(SignalPaneMetadata {
6694 title: None,
6695 agent_title: Some("Codex".into()),
6696 cwd: None,
6697 repo_name: None,
6698 git_branch: None,
6699 ports: Vec::new(),
6700 agent_kind: Some("codex".into()),
6701 agent_active: Some(false),
6702 agent_command: None,
6703 }),
6704 timestamp: stale_timestamp,
6705 },
6706 )
6707 .expect("completed signal applied");
6708
6709 model
6710 .apply_signal(
6711 workspace_id,
6712 pane_id,
6713 SignalEvent::with_metadata(
6714 "test",
6715 SignalKind::Metadata,
6716 None,
6717 Some(SignalPaneMetadata {
6718 title: Some("taskers".into()),
6719 agent_title: None,
6720 cwd: Some("/tmp".into()),
6721 repo_name: Some("taskers".into()),
6722 git_branch: Some("main".into()),
6723 ports: Vec::new(),
6724 agent_kind: Some("shell".into()),
6725 agent_active: Some(false),
6726 agent_command: None,
6727 }),
6728 ),
6729 )
6730 .expect("metadata signal applied");
6731
6732 let summaries = model
6733 .workspace_summaries(model.active_window)
6734 .expect("workspace summaries");
6735 assert!(
6736 summaries
6737 .first()
6738 .expect("summary")
6739 .agent_summaries
6740 .is_empty()
6741 );
6742
6743 let surface = model
6744 .active_workspace()
6745 .and_then(|workspace| workspace.panes.get(&pane_id))
6746 .and_then(PaneRecord::active_surface)
6747 .expect("surface");
6748 assert_eq!(surface.metadata.agent_kind, None);
6749 assert_eq!(surface.metadata.agent_title, None);
6750 assert_eq!(surface.metadata.agent_state, None);
6751 assert_eq!(surface.metadata.latest_agent_message, None);
6752 assert_eq!(surface.attention, AttentionState::Normal);
6753 assert_eq!(surface.metadata.last_signal_at, None);
6754 }
6755
6756 #[test]
6757 fn marking_surface_completed_clears_activity_and_keeps_recent_completed_status() {
6758 let mut model = AppModel::new("Main");
6759 let workspace_id = model.active_workspace_id().expect("workspace");
6760 let pane_id = model.active_workspace().expect("workspace").active_pane;
6761 let surface_id = model
6762 .active_workspace()
6763 .and_then(|workspace| workspace.panes.get(&pane_id))
6764 .map(|pane| pane.active_surface)
6765 .expect("surface id");
6766
6767 model
6768 .apply_signal(
6769 workspace_id,
6770 pane_id,
6771 SignalEvent::with_metadata(
6772 "test",
6773 SignalKind::WaitingInput,
6774 Some("Need review".into()),
6775 Some(SignalPaneMetadata {
6776 title: None,
6777 agent_title: Some("Codex".into()),
6778 cwd: None,
6779 repo_name: None,
6780 git_branch: None,
6781 ports: Vec::new(),
6782 agent_kind: Some("codex".into()),
6783 agent_active: Some(true),
6784 agent_command: None,
6785 }),
6786 ),
6787 )
6788 .expect("waiting signal applied");
6789
6790 assert_eq!(model.activity_items().len(), 1);
6791
6792 model
6793 .mark_surface_completed(workspace_id, pane_id, surface_id)
6794 .expect("mark completed");
6795
6796 let surface = model
6797 .active_workspace()
6798 .and_then(|workspace| workspace.panes.get(&pane_id))
6799 .and_then(PaneRecord::active_surface)
6800 .expect("surface");
6801 assert_eq!(surface.attention, AttentionState::Normal);
6802 assert!(model.activity_items().is_empty());
6803
6804 let summaries = model
6805 .workspace_summaries(model.active_window)
6806 .expect("workspace summaries");
6807 assert_eq!(
6808 summaries
6809 .first()
6810 .and_then(|summary| summary.agent_summaries.first())
6811 .map(|summary| summary.state),
6812 None
6813 );
6814 }
6815
6816 #[test]
6817 fn metadata_inactive_resolves_waiting_agent_state() {
6818 let mut model = AppModel::new("Main");
6819 let workspace_id = model.active_workspace_id().expect("workspace");
6820 let pane_id = model.active_workspace().expect("workspace").active_pane;
6821
6822 model
6823 .apply_signal(
6824 workspace_id,
6825 pane_id,
6826 SignalEvent::with_metadata(
6827 "test",
6828 SignalKind::WaitingInput,
6829 Some("Need input".into()),
6830 Some(SignalPaneMetadata {
6831 title: None,
6832 agent_title: Some("Codex".into()),
6833 cwd: None,
6834 repo_name: None,
6835 git_branch: None,
6836 ports: Vec::new(),
6837 agent_kind: Some("codex".into()),
6838 agent_active: Some(true),
6839 agent_command: None,
6840 }),
6841 ),
6842 )
6843 .expect("waiting signal applied");
6844
6845 model
6846 .apply_signal(
6847 workspace_id,
6848 pane_id,
6849 SignalEvent::with_metadata(
6850 "test",
6851 SignalKind::Metadata,
6852 None,
6853 Some(SignalPaneMetadata {
6854 title: Some("codex :: taskers".into()),
6855 agent_title: None,
6856 cwd: Some("/tmp".into()),
6857 repo_name: Some("taskers".into()),
6858 git_branch: Some("main".into()),
6859 ports: Vec::new(),
6860 agent_kind: Some("codex".into()),
6861 agent_active: Some(false),
6862 agent_command: None,
6863 }),
6864 ),
6865 )
6866 .expect("metadata signal applied");
6867
6868 let workspace = model.active_workspace().expect("workspace");
6869 let surface = workspace
6870 .panes
6871 .get(&pane_id)
6872 .and_then(PaneRecord::active_surface)
6873 .expect("surface");
6874 assert_eq!(surface.attention, AttentionState::Normal);
6875 assert!(surface.agent_session.is_none());
6876 assert!(!surface.metadata.agent_active);
6877 assert_eq!(surface.metadata.agent_state, None);
6878 assert_eq!(surface.metadata.latest_agent_message, None);
6879 assert_eq!(surface.metadata.last_signal_at, None);
6880 assert!(
6881 workspace
6882 .notifications
6883 .iter()
6884 .all(|item| item.cleared_at.is_some())
6885 );
6886
6887 let summaries = model
6888 .workspace_summaries(model.active_window)
6889 .expect("workspace summaries");
6890 assert_eq!(
6891 summaries
6892 .first()
6893 .and_then(|summary| summary.agent_summaries.first())
6894 .map(|summary| summary.state),
6895 None
6896 );
6897 }
6898
6899 #[test]
6900 fn focusing_waiting_agent_does_not_clear_attention_item() {
6901 let mut model = AppModel::new("Main");
6902 let workspace_id = model.active_workspace_id().expect("workspace");
6903 let window_id = model.active_window;
6904 let pane_id = model.active_workspace().expect("workspace").active_pane;
6905 let surface_id = model
6906 .active_workspace()
6907 .and_then(|workspace| workspace.panes.get(&pane_id))
6908 .map(|pane| pane.active_surface)
6909 .expect("surface id");
6910
6911 model
6912 .apply_signal(
6913 workspace_id,
6914 pane_id,
6915 SignalEvent::with_metadata(
6916 "test",
6917 SignalKind::WaitingInput,
6918 Some("Need review".into()),
6919 Some(SignalPaneMetadata {
6920 title: None,
6921 agent_title: Some("Codex".into()),
6922 cwd: None,
6923 repo_name: None,
6924 git_branch: None,
6925 ports: Vec::new(),
6926 agent_kind: Some("codex".into()),
6927 agent_active: Some(true),
6928 agent_command: None,
6929 }),
6930 ),
6931 )
6932 .expect("waiting signal applied");
6933
6934 let other_workspace_id = model.create_workspace("Docs");
6935 assert_eq!(model.activity_items().len(), 1);
6936
6937 model
6938 .switch_workspace(window_id, workspace_id)
6939 .expect("switch back to waiting workspace");
6940 model
6941 .focus_surface(workspace_id, pane_id, surface_id)
6942 .expect("focus waiting surface");
6943
6944 let activity_items = model.activity_items();
6945 assert_eq!(activity_items.len(), 1);
6946 assert_eq!(activity_items[0].state, AttentionState::WaitingInput);
6947 assert_eq!(
6948 model
6949 .workspaces
6950 .get(&workspace_id)
6951 .expect("workspace")
6952 .notifications
6953 .iter()
6954 .filter(|item| item.cleared_at.is_none())
6955 .count(),
6956 1
6957 );
6958 assert_eq!(model.active_workspace_id(), Some(workspace_id));
6959 assert_ne!(workspace_id, other_workspace_id);
6960 }
6961
6962 #[test]
6963 fn workspace_agent_state_flows_into_summary_and_logs_are_bounded() {
6964 let mut model = AppModel::new("Main");
6965 let workspace_id = model.active_workspace_id().expect("workspace");
6966
6967 model
6968 .set_workspace_status(workspace_id, "Running import".into())
6969 .expect("set status");
6970 model
6971 .set_workspace_progress(
6972 workspace_id,
6973 ProgressState {
6974 value: 420,
6975 label: Some("42%".into()),
6976 },
6977 )
6978 .expect("set progress");
6979
6980 for index in 0..205 {
6981 model
6982 .append_workspace_log(
6983 workspace_id,
6984 WorkspaceLogEntry {
6985 source: Some("codex".into()),
6986 message: format!("log {index}"),
6987 created_at: OffsetDateTime::now_utc(),
6988 },
6989 )
6990 .expect("append log");
6991 }
6992
6993 let summary = model
6994 .workspace_summaries(model.active_window)
6995 .expect("workspace summaries")
6996 .into_iter()
6997 .find(|summary| summary.workspace_id == workspace_id)
6998 .expect("workspace summary");
6999 let workspace = model.workspaces.get(&workspace_id).expect("workspace");
7000
7001 assert_eq!(summary.status_text.as_deref(), Some("Running import"));
7002 assert_eq!(
7003 workspace.progress.as_ref().map(|progress| progress.value),
7004 Some(420)
7005 );
7006 assert_eq!(workspace.log_entries.len(), 200);
7007 assert_eq!(
7008 workspace
7009 .log_entries
7010 .first()
7011 .map(|entry| entry.message.as_str()),
7012 Some("log 5")
7013 );
7014 assert_eq!(
7015 workspace
7016 .log_entries
7017 .last()
7018 .map(|entry| entry.message.as_str()),
7019 Some("log 204")
7020 );
7021 }
7022
7023 #[test]
7024 fn focusing_latest_unread_prefers_newest_notification_in_active_window() {
7025 let mut model = AppModel::new("Main");
7026 let workspace_id = model.active_workspace_id().expect("workspace");
7027 let pane_id = model.active_workspace().expect("workspace").active_pane;
7028 let surface_id = model
7029 .active_workspace()
7030 .and_then(|workspace| workspace.panes.get(&pane_id))
7031 .map(|pane| pane.active_surface)
7032 .expect("surface");
7033 let second_workspace_id = model.create_workspace("Secondary");
7034
7035 model
7036 .create_agent_notification(
7037 AgentTarget::Surface {
7038 workspace_id,
7039 pane_id,
7040 surface_id,
7041 },
7042 SignalKind::Notification,
7043 Some("Older".into()),
7044 None,
7045 None,
7046 "First".into(),
7047 AttentionState::WaitingInput,
7048 )
7049 .expect("older notification");
7050 std::thread::sleep(std::time::Duration::from_millis(2));
7051 model
7052 .create_agent_notification(
7053 AgentTarget::Workspace {
7054 workspace_id: second_workspace_id,
7055 },
7056 SignalKind::Notification,
7057 Some("Newest".into()),
7058 None,
7059 None,
7060 "Second".into(),
7061 AttentionState::WaitingInput,
7062 )
7063 .expect("newer notification");
7064
7065 let focused = model
7066 .focus_latest_unread(model.active_window)
7067 .expect("focus latest unread");
7068
7069 assert!(focused);
7070 assert_eq!(model.active_workspace_id(), Some(second_workspace_id));
7071 }
7072
7073 #[test]
7074 fn opening_notification_marks_it_read_without_clearing() {
7075 let mut model = AppModel::new("Main");
7076 let workspace_id = model.active_workspace_id().expect("workspace");
7077 let pane_id = model.active_workspace().expect("workspace").active_pane;
7078 let surface_id = model
7079 .active_workspace()
7080 .and_then(|workspace| workspace.panes.get(&pane_id))
7081 .and_then(|pane| pane.active_surface())
7082 .map(|surface| surface.id)
7083 .expect("surface");
7084
7085 model
7086 .create_agent_notification(
7087 AgentTarget::Surface {
7088 workspace_id,
7089 pane_id,
7090 surface_id,
7091 },
7092 SignalKind::Notification,
7093 Some("Heads up".into()),
7094 None,
7095 None,
7096 "Review needed".into(),
7097 AttentionState::WaitingInput,
7098 )
7099 .expect("notification");
7100
7101 let notification_id = model
7102 .active_workspace()
7103 .and_then(|workspace| workspace.notifications.last())
7104 .map(|notification| notification.id)
7105 .expect("notification id");
7106
7107 model
7108 .open_notification(model.active_window, notification_id)
7109 .expect("open notification");
7110
7111 let notification = model
7112 .active_workspace()
7113 .and_then(|workspace| {
7114 workspace
7115 .notifications
7116 .iter()
7117 .find(|notification| notification.id == notification_id)
7118 })
7119 .expect("notification");
7120 assert!(notification.read_at.is_some());
7121 assert!(notification.cleared_at.is_none());
7122 assert_eq!(model.activity_items().len(), 1);
7123 assert!(
7124 model
7125 .activity_items()
7126 .iter()
7127 .all(|item| item.read_at.is_some())
7128 );
7129 }
7130
7131 #[test]
7132 fn focusing_hidden_pane_activates_its_container_tab() {
7133 let mut model = AppModel::new("Main");
7134 let workspace_id = model.active_workspace_id().expect("workspace");
7135 let first_pane_id = model.active_workspace().expect("workspace").active_pane;
7136 let (container_id, first_pane_tab_id) = {
7137 let workspace = model.active_workspace().expect("workspace");
7138 let (_, _, container_id, pane_tab_id) = workspace
7139 .pane_location(first_pane_id)
7140 .expect("pane location");
7141 (container_id, pane_tab_id)
7142 };
7143 let (second_pane_tab_id, second_pane_id) = model
7144 .create_pane_tab(workspace_id, container_id, PaneKind::Terminal)
7145 .expect("create pane tab");
7146
7147 model
7148 .focus_pane_tab(workspace_id, container_id, first_pane_tab_id)
7149 .expect("focus original pane tab");
7150 model
7151 .focus_pane(workspace_id, second_pane_id)
7152 .expect("focus hidden pane");
7153
7154 let workspace = model.active_workspace().expect("workspace");
7155 let container = workspace
7156 .pane_containers
7157 .get(&container_id)
7158 .expect("pane container");
7159 assert_eq!(container.active_tab, second_pane_tab_id);
7160 assert_eq!(workspace.active_pane, second_pane_id);
7161 }
7162
7163 #[test]
7164 fn opening_notification_surfaces_hidden_pane_tab() {
7165 let mut model = AppModel::new("Main");
7166 let workspace_id = model.active_workspace_id().expect("workspace");
7167 let first_pane_id = model.active_workspace().expect("workspace").active_pane;
7168 let (container_id, first_pane_tab_id) = {
7169 let workspace = model.active_workspace().expect("workspace");
7170 let (_, _, container_id, pane_tab_id) = workspace
7171 .pane_location(first_pane_id)
7172 .expect("pane location");
7173 (container_id, pane_tab_id)
7174 };
7175 let (second_pane_tab_id, second_pane_id) = model
7176 .create_pane_tab(workspace_id, container_id, PaneKind::Terminal)
7177 .expect("create pane tab");
7178 let second_surface_id = model
7179 .active_workspace()
7180 .and_then(|workspace| workspace.panes.get(&second_pane_id))
7181 .and_then(|pane| pane.active_surface())
7182 .map(|surface| surface.id)
7183 .expect("second surface");
7184
7185 model
7186 .focus_pane_tab(workspace_id, container_id, first_pane_tab_id)
7187 .expect("focus original pane tab");
7188 model
7189 .create_agent_notification(
7190 AgentTarget::Surface {
7191 workspace_id,
7192 pane_id: second_pane_id,
7193 surface_id: second_surface_id,
7194 },
7195 SignalKind::Notification,
7196 Some("Heads up".into()),
7197 None,
7198 None,
7199 "Review hidden pane".into(),
7200 AttentionState::WaitingInput,
7201 )
7202 .expect("notification");
7203 let notification_id = model
7204 .active_workspace()
7205 .and_then(|workspace| workspace.notifications.last())
7206 .map(|notification| notification.id)
7207 .expect("notification id");
7208
7209 model
7210 .open_notification(model.active_window, notification_id)
7211 .expect("open notification");
7212
7213 let workspace = model.active_workspace().expect("workspace");
7214 let container = workspace
7215 .pane_containers
7216 .get(&container_id)
7217 .expect("pane container");
7218 assert_eq!(container.active_tab, second_pane_tab_id);
7219 assert_eq!(workspace.active_pane, second_pane_id);
7220 }
7221
7222 #[test]
7223 fn agent_notifications_do_not_create_live_agent_sessions() {
7224 let mut model = AppModel::new("Main");
7225 let workspace_id = model.active_workspace_id().expect("workspace");
7226 let pane_id = model.active_workspace().expect("workspace").active_pane;
7227 let surface_id = model
7228 .active_workspace()
7229 .and_then(|workspace| workspace.panes.get(&pane_id))
7230 .and_then(|pane| pane.active_surface())
7231 .map(|surface| surface.id)
7232 .expect("surface");
7233
7234 model
7235 .create_agent_notification(
7236 AgentTarget::Surface {
7237 workspace_id,
7238 pane_id,
7239 surface_id,
7240 },
7241 SignalKind::Notification,
7242 Some("Codex".into()),
7243 None,
7244 None,
7245 "Need input".into(),
7246 AttentionState::WaitingInput,
7247 )
7248 .expect("notification");
7249
7250 let surface = model
7251 .active_workspace()
7252 .and_then(|workspace| workspace.panes.get(&pane_id))
7253 .and_then(|pane| pane.surfaces.get(&surface_id))
7254 .expect("surface record");
7255 assert!(surface.agent_session.is_none());
7256 assert_eq!(surface.metadata.agent_kind.as_deref(), Some("codex"));
7257 assert_eq!(surface.metadata.agent_title.as_deref(), Some("Codex"));
7258 assert_eq!(surface.metadata.agent_state, None);
7259 assert!(!surface.metadata.agent_active);
7260 assert_eq!(
7261 surface.metadata.latest_agent_message.as_deref(),
7262 Some("Need input")
7263 );
7264 assert_eq!(surface.attention, AttentionState::WaitingInput);
7265 }
7266
7267 #[test]
7268 fn clearing_notification_moves_it_out_of_active_activity() {
7269 let mut model = AppModel::new("Main");
7270 let workspace_id = model.active_workspace_id().expect("workspace");
7271 let pane_id = model.active_workspace().expect("workspace").active_pane;
7272 let surface_id = model
7273 .active_workspace()
7274 .and_then(|workspace| workspace.panes.get(&pane_id))
7275 .and_then(|pane| pane.active_surface())
7276 .map(|surface| surface.id)
7277 .expect("surface");
7278
7279 model
7280 .create_agent_notification(
7281 AgentTarget::Surface {
7282 workspace_id,
7283 pane_id,
7284 surface_id,
7285 },
7286 SignalKind::Notification,
7287 Some("Heads up".into()),
7288 None,
7289 None,
7290 "Review needed".into(),
7291 AttentionState::WaitingInput,
7292 )
7293 .expect("notification");
7294
7295 let notification_id = model
7296 .active_workspace()
7297 .and_then(|workspace| workspace.notifications.last())
7298 .map(|notification| notification.id)
7299 .expect("notification id");
7300
7301 model
7302 .clear_notification(notification_id)
7303 .expect("clear notification");
7304
7305 assert!(model.activity_items().is_empty());
7306 let notification = model
7307 .active_workspace()
7308 .and_then(|workspace| {
7309 workspace
7310 .notifications
7311 .iter()
7312 .find(|notification| notification.id == notification_id)
7313 })
7314 .expect("notification");
7315 assert!(notification.read_at.is_some());
7316 assert!(notification.cleared_at.is_some());
7317 let surface = model
7318 .active_workspace()
7319 .and_then(|workspace| workspace.panes.get(&pane_id))
7320 .and_then(|pane| pane.surfaces.get(&surface_id))
7321 .expect("surface");
7322 assert_eq!(surface.attention, AttentionState::Normal);
7323 }
7324
7325 #[test]
7326 fn clearing_notification_keeps_surface_attention_when_another_alert_is_still_active() {
7327 let mut model = AppModel::new("Main");
7328 let workspace_id = model.active_workspace_id().expect("workspace");
7329 let pane_id = model.active_workspace().expect("workspace").active_pane;
7330 let surface_id = model
7331 .active_workspace()
7332 .and_then(|workspace| workspace.panes.get(&pane_id))
7333 .and_then(|pane| pane.active_surface())
7334 .map(|surface| surface.id)
7335 .expect("surface");
7336
7337 model
7338 .create_agent_notification(
7339 AgentTarget::Surface {
7340 workspace_id,
7341 pane_id,
7342 surface_id,
7343 },
7344 SignalKind::Notification,
7345 Some("Heads up".into()),
7346 None,
7347 None,
7348 "Review needed".into(),
7349 AttentionState::WaitingInput,
7350 )
7351 .expect("waiting notification");
7352 model
7353 .create_agent_notification(
7354 AgentTarget::Surface {
7355 workspace_id,
7356 pane_id,
7357 surface_id,
7358 },
7359 SignalKind::Error,
7360 Some("Heads up".into()),
7361 None,
7362 None,
7363 "Build failed".into(),
7364 AttentionState::Error,
7365 )
7366 .expect("error notification");
7367
7368 let first_notification_id = model
7369 .active_workspace()
7370 .and_then(|workspace| workspace.notifications.first())
7371 .map(|notification| notification.id)
7372 .expect("notification id");
7373
7374 model
7375 .clear_notification(first_notification_id)
7376 .expect("clear notification");
7377
7378 let surface = model
7379 .active_workspace()
7380 .and_then(|workspace| workspace.panes.get(&pane_id))
7381 .and_then(|pane| pane.surfaces.get(&surface_id))
7382 .expect("surface");
7383 assert_eq!(surface.attention, AttentionState::Error);
7384 }
7385
7386 #[test]
7387 fn dismiss_surface_alert_clears_completed_agent_presentation() {
7388 let mut model = AppModel::new("Main");
7389 let workspace_id = model.active_workspace_id().expect("workspace");
7390 let pane_id = model.active_workspace().expect("workspace").active_pane;
7391 let surface_id = model
7392 .active_workspace()
7393 .and_then(|workspace| workspace.panes.get(&pane_id))
7394 .and_then(|pane| pane.active_surface())
7395 .map(|surface| surface.id)
7396 .expect("surface");
7397
7398 model
7399 .create_agent_notification(
7400 AgentTarget::Surface {
7401 workspace_id,
7402 pane_id,
7403 surface_id,
7404 },
7405 SignalKind::Completed,
7406 Some("Codex".into()),
7407 None,
7408 None,
7409 "Finished".into(),
7410 AttentionState::Completed,
7411 )
7412 .expect("completed notification");
7413
7414 model
7415 .dismiss_surface_alert(workspace_id, pane_id, surface_id)
7416 .expect("dismiss alert");
7417
7418 let surface = model
7419 .active_workspace()
7420 .and_then(|workspace| workspace.panes.get(&pane_id))
7421 .and_then(|pane| pane.surfaces.get(&surface_id))
7422 .expect("surface");
7423 assert_eq!(surface.attention, AttentionState::Normal);
7424 assert_eq!(surface.metadata.agent_active, false);
7425 assert_eq!(surface.metadata.agent_state, None);
7426 assert_eq!(surface.metadata.agent_title, None);
7427 assert_eq!(surface.metadata.agent_kind, None);
7428 assert_eq!(surface.metadata.last_signal_at, None);
7429 assert_eq!(surface.metadata.latest_agent_message, None);
7430 }
7431
7432 #[test]
7433 fn dismiss_surface_alert_clears_working_agent_presentation() {
7434 let mut model = AppModel::new("Main");
7435 let workspace_id = model.active_workspace_id().expect("workspace");
7436 let pane_id = model.active_workspace().expect("workspace").active_pane;
7437 let surface_id = model
7438 .active_workspace()
7439 .and_then(|workspace| workspace.panes.get(&pane_id))
7440 .and_then(|pane| pane.active_surface())
7441 .map(|surface| surface.id)
7442 .expect("surface");
7443
7444 model
7445 .start_surface_agent_session(workspace_id, pane_id, surface_id, "codex".into())
7446 .expect("working session");
7447 model
7448 .apply_surface_signal(
7449 workspace_id,
7450 pane_id,
7451 surface_id,
7452 SignalEvent::with_metadata(
7453 "agent-hook:codex",
7454 SignalKind::Started,
7455 Some("Working".into()),
7456 Some(SignalPaneMetadata {
7457 title: None,
7458 agent_title: Some("Codex".into()),
7459 cwd: None,
7460 repo_name: None,
7461 git_branch: None,
7462 ports: Vec::new(),
7463 agent_kind: Some("codex".into()),
7464 agent_active: Some(true),
7465 agent_command: None,
7466 }),
7467 ),
7468 )
7469 .expect("started signal applied");
7470
7471 model
7472 .dismiss_surface_alert(workspace_id, pane_id, surface_id)
7473 .expect("dismiss alert");
7474
7475 let surface = model
7476 .active_workspace()
7477 .and_then(|workspace| workspace.panes.get(&pane_id))
7478 .and_then(|pane| pane.surfaces.get(&surface_id))
7479 .expect("surface");
7480 assert_eq!(surface.attention, AttentionState::Normal);
7481 assert!(surface.agent_process.is_some());
7482 assert!(surface.agent_session.is_none());
7483 assert_eq!(surface.metadata.agent_active, true);
7484 assert_eq!(surface.metadata.agent_state, None);
7485 assert_eq!(surface.metadata.agent_title.as_deref(), Some("Codex"));
7486 assert_eq!(surface.metadata.agent_kind.as_deref(), Some("codex"));
7487 assert_eq!(surface.metadata.last_signal_at, None);
7488 assert_eq!(surface.metadata.latest_agent_message, None);
7489 }
7490
7491 #[test]
7492 fn late_agent_notification_does_not_recreate_live_session_after_stop() {
7493 let mut model = AppModel::new("Main");
7494 let workspace_id = model.active_workspace_id().expect("workspace");
7495 let pane_id = model.active_workspace().expect("workspace").active_pane;
7496 let surface_id = model
7497 .active_workspace()
7498 .and_then(|workspace| workspace.panes.get(&pane_id))
7499 .and_then(|pane| pane.active_surface())
7500 .map(|surface| surface.id)
7501 .expect("surface");
7502
7503 model
7504 .start_surface_agent_session(workspace_id, pane_id, surface_id, "codex".into())
7505 .expect("start agent");
7506 model
7507 .stop_surface_agent_session(workspace_id, pane_id, surface_id, 0)
7508 .expect("stop agent");
7509 model
7510 .apply_surface_signal(
7511 workspace_id,
7512 pane_id,
7513 surface_id,
7514 SignalEvent {
7515 source: "agent-hook:codex".into(),
7516 kind: SignalKind::Notification,
7517 message: Some("Turn complete".into()),
7518 metadata: Some(SignalPaneMetadata {
7519 title: None,
7520 agent_title: Some("Codex".into()),
7521 cwd: None,
7522 repo_name: None,
7523 git_branch: None,
7524 ports: Vec::new(),
7525 agent_kind: Some("codex".into()),
7526 agent_active: Some(true),
7527 agent_command: None,
7528 }),
7529 timestamp: OffsetDateTime::now_utc(),
7530 },
7531 )
7532 .expect("late notification");
7533
7534 let surface = model
7535 .active_workspace()
7536 .and_then(|workspace| workspace.panes.get(&pane_id))
7537 .and_then(|pane| pane.surfaces.get(&surface_id))
7538 .expect("surface record");
7539 assert!(surface.agent_session.is_none());
7540 assert_eq!(surface.attention, AttentionState::WaitingInput);
7541 }
7542
7543 #[test]
7544 fn recover_interrupted_agent_resume_converts_live_agent_into_resume_offer() {
7545 let mut model = AppModel::new("Main");
7546 let workspace_id = model.active_workspace_id().expect("workspace");
7547 let pane_id = model.active_workspace().expect("workspace").active_pane;
7548 let surface_id = model
7549 .active_workspace()
7550 .and_then(|workspace| workspace.panes.get(&pane_id))
7551 .and_then(|pane| pane.active_surface())
7552 .map(|surface| surface.id)
7553 .expect("surface");
7554
7555 model
7556 .start_surface_agent_session(workspace_id, pane_id, surface_id, "codex".into())
7557 .expect("start agent");
7558 model
7559 .apply_surface_signal(
7560 workspace_id,
7561 pane_id,
7562 surface_id,
7563 SignalEvent::with_metadata(
7564 "shell",
7565 SignalKind::Metadata,
7566 None,
7567 Some(SignalPaneMetadata {
7568 title: Some("codex :: taskers".into()),
7569 agent_title: Some("Codex".into()),
7570 cwd: Some("/tmp/taskers".into()),
7571 repo_name: Some("taskers".into()),
7572 git_branch: Some("main".into()),
7573 ports: Vec::new(),
7574 agent_kind: Some("codex".into()),
7575 agent_active: Some(true),
7576 agent_command: Some("codex --model gpt-5".into()),
7577 }),
7578 ),
7579 )
7580 .expect("metadata signal applied");
7581
7582 assert_eq!(model.recover_interrupted_agent_resumes(), 1);
7583
7584 let surface = model
7585 .active_workspace()
7586 .and_then(|workspace| workspace.panes.get(&pane_id))
7587 .and_then(|pane| pane.surfaces.get(&surface_id))
7588 .expect("surface");
7589 let resume = surface
7590 .interrupted_agent_resume
7591 .as_ref()
7592 .expect("resume offer");
7593 assert_eq!(resume.kind, "codex");
7594 assert_eq!(resume.title, "Codex");
7595 assert_eq!(resume.command, "codex --model gpt-5");
7596 assert_eq!(resume.cwd.as_deref(), Some("/tmp/taskers"));
7597 assert!(surface.agent_process.is_none());
7598 assert!(surface.agent_session.is_none());
7599 assert_eq!(surface.attention, AttentionState::WaitingInput);
7600 assert_eq!(surface.metadata.agent_active, false);
7601 assert_eq!(
7602 model
7603 .activity_items()
7604 .iter()
7605 .any(|item| item.surface_id == surface_id),
7606 true
7607 );
7608 }
7609
7610 #[test]
7611 fn recover_interrupted_agent_resume_clears_stale_agent_without_command() {
7612 let mut model = AppModel::new("Main");
7613 let workspace_id = model.active_workspace_id().expect("workspace");
7614 let pane_id = model.active_workspace().expect("workspace").active_pane;
7615 let surface_id = model
7616 .active_workspace()
7617 .and_then(|workspace| workspace.panes.get(&pane_id))
7618 .and_then(|pane| pane.active_surface())
7619 .map(|surface| surface.id)
7620 .expect("surface");
7621
7622 model
7623 .start_surface_agent_session(workspace_id, pane_id, surface_id, "codex".into())
7624 .expect("start agent");
7625
7626 assert_eq!(model.recover_interrupted_agent_resumes(), 0);
7627
7628 let surface = model
7629 .active_workspace()
7630 .and_then(|workspace| workspace.panes.get(&pane_id))
7631 .and_then(|pane| pane.surfaces.get(&surface_id))
7632 .expect("surface");
7633 assert!(surface.interrupted_agent_resume.is_none());
7634 assert!(surface.agent_process.is_none());
7635 assert!(surface.agent_session.is_none());
7636 assert_eq!(surface.metadata.agent_command, None);
7637 assert_eq!(surface.metadata.agent_kind, None);
7638 assert_eq!(surface.attention, AttentionState::Normal);
7639 }
7640
7641 #[test]
7642 fn recover_interrupted_agent_resume_skips_surfaces_with_live_terminal_sessions() {
7643 let mut model = AppModel::new("Main");
7644 let workspace_id = model.active_workspace_id().expect("workspace");
7645 let pane_id = model.active_workspace().expect("workspace").active_pane;
7646 let surface = model
7647 .active_workspace()
7648 .and_then(|workspace| workspace.panes.get(&pane_id))
7649 .and_then(|pane| pane.active_surface())
7650 .cloned()
7651 .expect("surface");
7652
7653 model
7654 .start_surface_agent_session(workspace_id, pane_id, surface.id, "codex".into())
7655 .expect("start agent");
7656 model
7657 .apply_surface_signal(
7658 workspace_id,
7659 pane_id,
7660 surface.id,
7661 SignalEvent::with_metadata(
7662 "shell",
7663 SignalKind::Metadata,
7664 None,
7665 Some(SignalPaneMetadata {
7666 title: Some("codex :: taskers".into()),
7667 agent_title: Some("Codex".into()),
7668 cwd: Some("/tmp/taskers".into()),
7669 repo_name: Some("taskers".into()),
7670 git_branch: Some("main".into()),
7671 ports: Vec::new(),
7672 agent_kind: Some("codex".into()),
7673 agent_active: Some(true),
7674 agent_command: Some("codex --model gpt-5".into()),
7675 }),
7676 ),
7677 )
7678 .expect("metadata signal applied");
7679
7680 assert_eq!(
7681 model.recover_interrupted_agent_resumes_for_missing_sessions(|session_id| {
7682 session_id == surface.session_id
7683 }),
7684 0
7685 );
7686
7687 let surface = model
7688 .active_workspace()
7689 .and_then(|workspace| workspace.panes.get(&pane_id))
7690 .and_then(|pane| pane.surfaces.get(&surface.id))
7691 .expect("surface");
7692 assert!(surface.interrupted_agent_resume.is_none());
7693 assert!(surface.agent_process.is_some());
7694 assert_eq!(surface.metadata.agent_active, true);
7695 }
7696
7697 #[test]
7698 fn triggering_surface_flash_advances_workspace_flash_token() {
7699 let mut model = AppModel::new("Main");
7700 let workspace_id = model.active_workspace_id().expect("workspace");
7701 let pane_id = model.active_workspace().expect("workspace").active_pane;
7702 let surface_id = model
7703 .active_workspace()
7704 .and_then(|workspace| workspace.panes.get(&pane_id))
7705 .map(|pane| pane.active_surface)
7706 .expect("surface");
7707
7708 model
7709 .trigger_surface_flash(workspace_id, pane_id, surface_id)
7710 .expect("trigger first flash");
7711 let first_token = model
7712 .workspaces
7713 .get(&workspace_id)
7714 .and_then(|workspace| workspace.surface_flash_tokens.get(&surface_id))
7715 .copied()
7716 .expect("first flash token");
7717 model
7718 .trigger_surface_flash(workspace_id, pane_id, surface_id)
7719 .expect("trigger second flash");
7720 let second_token = model
7721 .workspaces
7722 .get(&workspace_id)
7723 .and_then(|workspace| workspace.surface_flash_tokens.get(&surface_id))
7724 .copied()
7725 .expect("second flash token");
7726
7727 assert!(second_token > first_token);
7728 }
7729
7730 #[test]
7731 fn creating_workspace_window_tab_adds_blank_terminal_tab_and_focuses_it() {
7732 let mut model = AppModel::new("Main");
7733 let workspace_id = model.active_workspace_id().expect("workspace");
7734 let window_id = model.active_workspace().expect("workspace").active_window;
7735
7736 let (tab_id, pane_id) = model
7737 .create_workspace_window_tab(workspace_id, window_id)
7738 .expect("create window tab");
7739
7740 let workspace = model.active_workspace().expect("workspace");
7741 let window = workspace.windows.get(&window_id).expect("window");
7742 let pane = workspace.panes.get(&pane_id).expect("new pane");
7743
7744 assert_eq!(window.tabs.len(), 2);
7745 assert_eq!(window.active_tab, tab_id);
7746 assert_eq!(workspace.active_window, window_id);
7747 assert_eq!(workspace.active_pane, pane_id);
7748 assert_eq!(pane.surfaces.len(), 1);
7749 assert_eq!(
7750 pane.active_surface().map(|surface| surface.kind.clone()),
7751 Some(PaneKind::Terminal)
7752 );
7753 }
7754
7755 #[test]
7756 fn closing_last_window_tab_closes_workspace_window() {
7757 let mut model = AppModel::new("Main");
7758 let workspace_id = model.active_workspace_id().expect("workspace");
7759 let first_window_id = model.active_workspace().expect("workspace").active_window;
7760
7761 model
7762 .create_workspace_window(workspace_id, Direction::Right)
7763 .expect("create second window");
7764
7765 let closing_tab_id = model
7766 .workspaces
7767 .get(&workspace_id)
7768 .and_then(|workspace| workspace.windows.get(&first_window_id))
7769 .map(|window| window.active_tab)
7770 .expect("window tab");
7771
7772 model
7773 .close_workspace_window_tab(workspace_id, first_window_id, closing_tab_id)
7774 .expect("close last tab");
7775
7776 let workspace = model.active_workspace().expect("workspace");
7777 assert!(!workspace.windows.contains_key(&first_window_id));
7778 assert_eq!(workspace.windows.len(), 1);
7779 }
7780
7781 #[test]
7782 fn transferring_window_tab_merges_into_target_window() {
7783 let mut model = AppModel::new("Main");
7784 let workspace_id = model.active_workspace_id().expect("workspace");
7785 let source_window_id = model.active_workspace().expect("workspace").active_window;
7786 let (tab_id, _) = model
7787 .create_workspace_window_tab(workspace_id, source_window_id)
7788 .expect("create second tab");
7789 let target_pane_id = model
7790 .create_workspace_window(workspace_id, Direction::Right)
7791 .expect("create second window");
7792 let target_window_id = model
7793 .active_workspace()
7794 .and_then(|workspace| workspace.window_for_pane(target_pane_id))
7795 .expect("target window");
7796
7797 model
7798 .transfer_workspace_window_tab(
7799 workspace_id,
7800 source_window_id,
7801 tab_id,
7802 target_window_id,
7803 usize::MAX,
7804 )
7805 .expect("transfer window tab");
7806
7807 let workspace = model.active_workspace().expect("workspace");
7808 assert_eq!(
7809 workspace
7810 .windows
7811 .get(&source_window_id)
7812 .map(|window| window.tabs.len()),
7813 Some(1)
7814 );
7815 assert_eq!(
7816 workspace
7817 .windows
7818 .get(&target_window_id)
7819 .map(|window| window.tabs.len()),
7820 Some(2)
7821 );
7822 assert_eq!(workspace.active_window, target_window_id);
7823 }
7824
7825 #[test]
7826 fn transferring_pane_tab_merges_into_target_container() {
7827 let mut model = AppModel::new("Main");
7828 let workspace_id = model.active_workspace_id().expect("workspace");
7829 let source_pane_id = model.active_workspace().expect("workspace").active_pane;
7830 let (source_container_id, source_original_tab_id) = {
7831 let workspace = model.active_workspace().expect("workspace");
7832 let (_, _, container_id, pane_tab_id) = workspace
7833 .pane_location(source_pane_id)
7834 .expect("pane location");
7835 (container_id, pane_tab_id)
7836 };
7837 let (moved_tab_id, moved_pane_id) = model
7838 .create_pane_tab(workspace_id, source_container_id, PaneKind::Terminal)
7839 .expect("create pane tab");
7840
7841 let target_pane_id = model
7842 .create_workspace_window(workspace_id, Direction::Right)
7843 .expect("create second window");
7844 let (target_window_id, _, target_container_id, target_original_tab_id) = {
7845 let workspace = model.active_workspace().expect("workspace");
7846 workspace
7847 .pane_location(target_pane_id)
7848 .expect("target pane location")
7849 };
7850
7851 model
7852 .transfer_pane_tab(
7853 workspace_id,
7854 source_container_id,
7855 moved_tab_id,
7856 target_container_id,
7857 usize::MAX,
7858 )
7859 .expect("transfer pane tab");
7860
7861 let workspace = model.active_workspace().expect("workspace");
7862 let source_container = workspace
7863 .pane_containers
7864 .get(&source_container_id)
7865 .expect("source container");
7866 let target_container = workspace
7867 .pane_containers
7868 .get(&target_container_id)
7869 .expect("target container");
7870
7871 assert_eq!(
7872 source_container.tabs.keys().copied().collect::<Vec<_>>(),
7873 vec![source_original_tab_id]
7874 );
7875 assert_eq!(
7876 target_container.tabs.keys().copied().collect::<Vec<_>>(),
7877 vec![target_original_tab_id, moved_tab_id]
7878 );
7879 assert_eq!(target_container.active_tab, moved_tab_id);
7880 assert_eq!(workspace.active_window, target_window_id);
7881 assert_eq!(workspace.active_pane, moved_pane_id);
7882 }
7883
7884 #[test]
7885 fn transferring_last_pane_tab_removes_empty_source_window() {
7886 let mut model = AppModel::new("Main");
7887 let workspace_id = model.active_workspace_id().expect("workspace");
7888 let source_window_id = model.active_workspace().expect("workspace").active_window;
7889 let source_pane_id = model.active_workspace().expect("workspace").active_pane;
7890 let (source_container_id, moved_tab_id) = {
7891 let workspace = model.active_workspace().expect("workspace");
7892 let (_, _, container_id, pane_tab_id) = workspace
7893 .pane_location(source_pane_id)
7894 .expect("pane location");
7895 (container_id, pane_tab_id)
7896 };
7897
7898 let target_pane_id = model
7899 .create_workspace_window(workspace_id, Direction::Right)
7900 .expect("create second window");
7901 let (target_window_id, _, target_container_id, _) = {
7902 let workspace = model.active_workspace().expect("workspace");
7903 workspace
7904 .pane_location(target_pane_id)
7905 .expect("target pane location")
7906 };
7907
7908 model
7909 .transfer_pane_tab(
7910 workspace_id,
7911 source_container_id,
7912 moved_tab_id,
7913 target_container_id,
7914 usize::MAX,
7915 )
7916 .expect("transfer last pane tab");
7917
7918 let workspace = model.active_workspace().expect("workspace");
7919 assert!(!workspace.windows.contains_key(&source_window_id));
7920 assert_eq!(workspace.active_window, target_window_id);
7921 assert_eq!(
7922 workspace
7923 .pane_containers
7924 .get(&target_container_id)
7925 .map(|container| container.tabs.len()),
7926 Some(2)
7927 );
7928 }
7929
7930 #[test]
7931 fn extracting_window_tab_creates_new_workspace_window() {
7932 let mut model = AppModel::new("Main");
7933 let workspace_id = model.active_workspace_id().expect("workspace");
7934 let source_window_id = model.active_workspace().expect("workspace").active_window;
7935 let (tab_id, pane_id) = model
7936 .create_workspace_window_tab(workspace_id, source_window_id)
7937 .expect("create second tab");
7938
7939 let extracted_window_id = model
7940 .extract_workspace_window_tab(
7941 workspace_id,
7942 source_window_id,
7943 tab_id,
7944 WorkspaceWindowMoveTarget::StackBelow {
7945 window_id: source_window_id,
7946 },
7947 )
7948 .expect("extract tab");
7949
7950 let workspace = model.active_workspace().expect("workspace");
7951 assert_eq!(workspace.windows.len(), 2);
7952 assert_eq!(
7953 workspace
7954 .windows
7955 .get(&source_window_id)
7956 .map(|window| window.tabs.len()),
7957 Some(1)
7958 );
7959 assert_eq!(
7960 workspace.window_for_pane(pane_id),
7961 Some(extracted_window_id)
7962 );
7963 assert_eq!(workspace.active_window, extracted_window_id);
7964 }
7965}