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