1use std::collections::BTreeMap;
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Deserializer, Serialize};
5use thiserror::Error;
6use time::OffsetDateTime;
7
8use crate::{
9 AttentionState, Direction, LayoutNode, PaneId, SessionId, SignalEvent, SignalKind, SplitAxis,
10 WindowId, WorkspaceId, WorkspaceWindowId,
11};
12
13pub const SESSION_SCHEMA_VERSION: u32 = 2;
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("workspace {workspace_id} does not contain pane {pane_id}")]
32 PaneNotInWorkspace {
33 workspace_id: WorkspaceId,
34 pane_id: PaneId,
35 },
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum PaneKind {
41 Terminal,
42 Browser,
43}
44
45#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
46pub struct PaneMetadata {
47 pub title: Option<String>,
48 pub cwd: Option<String>,
49 pub repo_name: Option<String>,
50 pub git_branch: Option<String>,
51 pub ports: Vec<u16>,
52 pub agent_kind: Option<String>,
53 pub last_signal_at: Option<OffsetDateTime>,
54}
55
56#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
57pub struct PaneMetadataPatch {
58 pub title: Option<String>,
59 pub cwd: Option<String>,
60 pub repo_name: Option<String>,
61 pub git_branch: Option<String>,
62 pub ports: Option<Vec<u16>>,
63 pub agent_kind: Option<String>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct PaneRecord {
68 pub id: PaneId,
69 pub kind: PaneKind,
70 pub metadata: PaneMetadata,
71 pub attention: AttentionState,
72 pub session_id: SessionId,
73 pub command: Option<Vec<String>>,
74}
75
76impl PaneRecord {
77 pub fn new(kind: PaneKind) -> Self {
78 Self {
79 id: PaneId::new(),
80 kind,
81 metadata: PaneMetadata::default(),
82 attention: AttentionState::Normal,
83 session_id: SessionId::new(),
84 command: None,
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct NotificationItem {
91 pub pane_id: PaneId,
92 pub state: AttentionState,
93 pub message: String,
94 pub created_at: OffsetDateTime,
95 pub cleared_at: Option<OffsetDateTime>,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct ActivityItem {
100 pub workspace_id: WorkspaceId,
101 pub pane_id: PaneId,
102 pub state: AttentionState,
103 pub message: String,
104 pub created_at: OffsetDateTime,
105}
106
107#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
108pub struct WorkspaceViewport {
109 #[serde(default)]
110 pub x: i32,
111 #[serde(default)]
112 pub y: i32,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116pub struct WindowFrame {
117 pub x: i32,
118 pub y: i32,
119 pub width: i32,
120 pub height: i32,
121}
122
123impl WindowFrame {
124 pub fn root() -> Self {
125 Self {
126 x: 0,
127 y: 0,
128 width: DEFAULT_WORKSPACE_WINDOW_WIDTH,
129 height: DEFAULT_WORKSPACE_WINDOW_HEIGHT,
130 }
131 }
132
133 pub fn right(self) -> i32 {
134 self.x + self.width
135 }
136
137 pub fn bottom(self) -> i32 {
138 self.y + self.height
139 }
140
141 pub fn center_x(self) -> i32 {
142 self.x + (self.width / 2)
143 }
144
145 pub fn center_y(self) -> i32 {
146 self.y + (self.height / 2)
147 }
148
149 pub fn shifted(self, direction: Direction) -> Self {
150 match direction {
151 Direction::Left => Self {
152 x: self.x - self.width - DEFAULT_WORKSPACE_WINDOW_GAP,
153 ..self
154 },
155 Direction::Right => Self {
156 x: self.x + self.width + DEFAULT_WORKSPACE_WINDOW_GAP,
157 ..self
158 },
159 Direction::Up => Self {
160 y: self.y - self.height - DEFAULT_WORKSPACE_WINDOW_GAP,
161 ..self
162 },
163 Direction::Down => Self {
164 y: self.y + self.height + DEFAULT_WORKSPACE_WINDOW_GAP,
165 ..self
166 },
167 }
168 }
169
170 pub fn resize_by_direction(&mut self, direction: Direction, amount: i32) {
171 match direction {
172 Direction::Left => {
173 self.width = (self.width - amount).max(MIN_WORKSPACE_WINDOW_WIDTH);
174 }
175 Direction::Right => {
176 self.width = (self.width + amount).max(MIN_WORKSPACE_WINDOW_WIDTH);
177 }
178 Direction::Up => {
179 self.height = (self.height - amount).max(MIN_WORKSPACE_WINDOW_HEIGHT);
180 }
181 Direction::Down => {
182 self.height = (self.height + amount).max(MIN_WORKSPACE_WINDOW_HEIGHT);
183 }
184 }
185 }
186
187 pub fn clamp(&mut self) {
188 self.width = self.width.max(MIN_WORKSPACE_WINDOW_WIDTH);
189 self.height = self.height.max(MIN_WORKSPACE_WINDOW_HEIGHT);
190 }
191
192 fn overlaps(self, other: Self) -> bool {
193 self.x < other.right()
194 && self.right() > other.x
195 && self.y < other.bottom()
196 && self.bottom() > other.y
197 }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct WorkspaceWindowRecord {
202 pub id: WorkspaceWindowId,
203 pub frame: WindowFrame,
204 pub layout: LayoutNode,
205 pub active_pane: PaneId,
206}
207
208impl WorkspaceWindowRecord {
209 fn new(frame: WindowFrame, pane_id: PaneId) -> Self {
210 Self {
211 id: WorkspaceWindowId::new(),
212 frame,
213 layout: LayoutNode::leaf(pane_id),
214 active_pane: pane_id,
215 }
216 }
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
220pub struct Workspace {
221 pub id: WorkspaceId,
222 pub label: String,
223 pub windows: IndexMap<WorkspaceWindowId, WorkspaceWindowRecord>,
224 pub active_window: WorkspaceWindowId,
225 pub panes: IndexMap<PaneId, PaneRecord>,
226 pub active_pane: PaneId,
227 #[serde(default)]
228 pub viewport: WorkspaceViewport,
229 pub notifications: Vec<NotificationItem>,
230}
231
232impl<'de> Deserialize<'de> for Workspace {
233 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
234 where
235 D: Deserializer<'de>,
236 {
237 let workspace = match WorkspaceSerdeCompat::deserialize(deserializer)? {
238 WorkspaceSerdeCompat::Current(current) => current.into_workspace(),
239 WorkspaceSerdeCompat::Legacy(legacy) => legacy.into_workspace(),
240 };
241 Ok(workspace)
242 }
243}
244
245impl Workspace {
246 pub fn bootstrap(label: impl Into<String>) -> Self {
247 let first_pane = PaneRecord::new(PaneKind::Terminal);
248 let active_pane = first_pane.id;
249 let mut panes = IndexMap::new();
250 panes.insert(active_pane, first_pane);
251 let first_window = WorkspaceWindowRecord::new(WindowFrame::root(), active_pane);
252 let active_window = first_window.id;
253 let mut windows = IndexMap::new();
254 windows.insert(active_window, first_window);
255
256 Self {
257 id: WorkspaceId::new(),
258 label: label.into(),
259 windows,
260 active_window,
261 panes,
262 active_pane,
263 viewport: WorkspaceViewport::default(),
264 notifications: Vec::new(),
265 }
266 }
267
268 pub fn active_window_record(&self) -> Option<&WorkspaceWindowRecord> {
269 self.windows.get(&self.active_window)
270 }
271
272 pub fn active_window_record_mut(&mut self) -> Option<&mut WorkspaceWindowRecord> {
273 self.windows.get_mut(&self.active_window)
274 }
275
276 pub fn window_for_pane(&self, pane_id: PaneId) -> Option<WorkspaceWindowId> {
277 self.windows
278 .iter()
279 .find_map(|(window_id, window)| window.layout.contains(pane_id).then_some(*window_id))
280 }
281
282 fn sync_active_from_window(&mut self, window_id: WorkspaceWindowId) {
283 if let Some(window) = self.windows.get(&window_id) {
284 self.active_window = window_id;
285 self.active_pane = window.active_pane;
286 }
287 }
288
289 fn focus_window(&mut self, window_id: WorkspaceWindowId) {
290 if let Some(window) = self.windows.get(&window_id) {
291 self.active_window = window_id;
292 self.active_pane = window.active_pane;
293 }
294 }
295
296 fn focus_pane(&mut self, pane_id: PaneId) -> bool {
297 let Some(window_id) = self.window_for_pane(pane_id) else {
298 return false;
299 };
300 if let Some(window) = self.windows.get_mut(&window_id) {
301 window.active_pane = pane_id;
302 }
303 self.sync_active_from_window(window_id);
304 true
305 }
306
307 fn next_window_frame(&self, source: WindowFrame, direction: Direction) -> WindowFrame {
308 let mut candidate = source.shifted(direction);
309 while self
310 .windows
311 .values()
312 .any(|window| window.frame.overlaps(candidate))
313 {
314 candidate = candidate.shifted(direction);
315 }
316 candidate
317 }
318
319 fn top_level_neighbor(
320 &self,
321 source_window_id: WorkspaceWindowId,
322 direction: Direction,
323 ) -> Option<WorkspaceWindowId> {
324 let source = self.windows.get(&source_window_id)?.frame;
325 self.windows
326 .iter()
327 .filter(|(window_id, _)| **window_id != source_window_id)
328 .filter_map(|(window_id, window)| {
329 let primary = match direction {
330 Direction::Left => source.center_x() - window.frame.center_x(),
331 Direction::Right => window.frame.center_x() - source.center_x(),
332 Direction::Up => source.center_y() - window.frame.center_y(),
333 Direction::Down => window.frame.center_y() - source.center_y(),
334 };
335 if primary <= 0 {
336 return None;
337 }
338
339 let secondary = match direction {
340 Direction::Left | Direction::Right => {
341 (window.frame.center_y() - source.center_y()).abs()
342 }
343 Direction::Up | Direction::Down => {
344 (window.frame.center_x() - source.center_x()).abs()
345 }
346 };
347 Some((*window_id, primary, secondary))
348 })
349 .min_by_key(|(_, primary, secondary)| (*primary, *secondary))
350 .map(|(window_id, _, _)| window_id)
351 }
352
353 fn fallback_window_after_close(&self, source: WindowFrame) -> Option<WorkspaceWindowId> {
354 [
355 Direction::Right,
356 Direction::Down,
357 Direction::Left,
358 Direction::Up,
359 ]
360 .into_iter()
361 .find_map(|direction| {
362 self.windows
363 .iter()
364 .filter_map(|(window_id, window)| {
365 let primary = match direction {
366 Direction::Left => source.center_x() - window.frame.center_x(),
367 Direction::Right => window.frame.center_x() - source.center_x(),
368 Direction::Up => source.center_y() - window.frame.center_y(),
369 Direction::Down => window.frame.center_y() - source.center_y(),
370 };
371 if primary <= 0 {
372 return None;
373 }
374 let secondary = match direction {
375 Direction::Left | Direction::Right => {
376 (window.frame.center_y() - source.center_y()).abs()
377 }
378 Direction::Up | Direction::Down => {
379 (window.frame.center_x() - source.center_x()).abs()
380 }
381 };
382 Some((*window_id, primary, secondary))
383 })
384 .min_by_key(|(_, primary, secondary)| (*primary, *secondary))
385 .map(|(window_id, _, _)| window_id)
386 })
387 .or_else(|| self.windows.first().map(|(window_id, _)| *window_id))
388 }
389
390 fn normalize(&mut self) {
391 if self.panes.is_empty() {
392 let id = self.id;
393 let label = self.label.clone();
394 *self = Self::bootstrap(label);
395 self.id = id;
396 return;
397 }
398
399 if self.windows.is_empty() {
400 let fallback_pane = self
401 .panes
402 .first()
403 .map(|(pane_id, _)| *pane_id)
404 .expect("workspace has at least one pane");
405 let fallback_window = WorkspaceWindowRecord::new(WindowFrame::root(), fallback_pane);
406 self.active_window = fallback_window.id;
407 self.active_pane = fallback_pane;
408 self.windows.insert(fallback_window.id, fallback_window);
409 }
410
411 for window in self.windows.values_mut() {
412 if !window.layout.contains(window.active_pane) {
413 window.active_pane = window
414 .layout
415 .leaves()
416 .into_iter()
417 .find(|pane_id| self.panes.contains_key(pane_id))
418 .or_else(|| self.panes.first().map(|(pane_id, _)| *pane_id))
419 .expect("workspace has at least one pane");
420 }
421 }
422
423 if !self.windows.contains_key(&self.active_window) {
424 self.active_window = self
425 .windows
426 .first()
427 .map(|(window_id, _)| *window_id)
428 .expect("workspace has at least one window");
429 }
430 if !self
431 .windows
432 .get(&self.active_window)
433 .is_some_and(|window| window.layout.contains(self.active_pane))
434 {
435 self.active_pane = self
436 .windows
437 .get(&self.active_window)
438 .map(|window| window.active_pane)
439 .expect("active window exists");
440 }
441 }
442
443 pub fn repo_hint(&self) -> Option<&str> {
444 self.panes
445 .values()
446 .find_map(|pane| pane.metadata.repo_name.as_deref())
447 }
448
449 pub fn attention_counts(&self) -> BTreeMap<AttentionState, usize> {
450 let mut counts = BTreeMap::new();
451 for pane in self.panes.values() {
452 *counts.entry(pane.attention).or_insert(0) += 1;
453 }
454 counts
455 }
456}
457
458#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
459pub struct WorkspaceSummary {
460 pub workspace_id: WorkspaceId,
461 pub label: String,
462 pub active_pane: PaneId,
463 pub repo_hint: Option<String>,
464 pub counts_by_attention: BTreeMap<AttentionState, usize>,
465 pub highest_attention: AttentionState,
466}
467
468#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
469pub struct WindowRecord {
470 pub id: WindowId,
471 pub workspace_order: Vec<WorkspaceId>,
472 pub active_workspace: WorkspaceId,
473}
474
475#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
476pub struct AppModel {
477 pub active_window: WindowId,
478 pub windows: IndexMap<WindowId, WindowRecord>,
479 pub workspaces: IndexMap<WorkspaceId, Workspace>,
480}
481
482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
483pub struct PersistedSession {
484 pub schema_version: u32,
485 pub captured_at: OffsetDateTime,
486 pub model: AppModel,
487}
488
489impl AppModel {
490 pub fn new(label: impl Into<String>) -> Self {
491 let window_id = WindowId::new();
492 let workspace = Workspace::bootstrap(label);
493 let workspace_id = workspace.id;
494
495 let mut windows = IndexMap::new();
496 windows.insert(
497 window_id,
498 WindowRecord {
499 id: window_id,
500 workspace_order: vec![workspace_id],
501 active_workspace: workspace_id,
502 },
503 );
504
505 let mut workspaces = IndexMap::new();
506 workspaces.insert(workspace_id, workspace);
507
508 Self {
509 active_window: window_id,
510 windows,
511 workspaces,
512 }
513 }
514
515 pub fn demo() -> Self {
516 let mut model = Self::new("Repo A");
517 let primary_workspace = model.active_workspace_id().unwrap_or_else(WorkspaceId::new);
518 let first_pane = model
519 .active_workspace()
520 .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
521 .unwrap_or_else(PaneId::new);
522
523 let _ = model.update_pane_metadata(
524 first_pane,
525 PaneMetadataPatch {
526 title: Some("Codex".into()),
527 cwd: Some("/home/notes/Projects/taskers".into()),
528 repo_name: Some("taskers".into()),
529 git_branch: Some("main".into()),
530 ports: Some(vec![3000]),
531 agent_kind: Some("codex".into()),
532 },
533 );
534 let _ = model.apply_signal(
535 primary_workspace,
536 first_pane,
537 SignalEvent::new(
538 "demo",
539 SignalKind::WaitingInput,
540 Some("Waiting for review on workspace bootstrap".into()),
541 ),
542 );
543
544 let second_window_pane = model
545 .create_workspace_window(primary_workspace, Direction::Right)
546 .unwrap_or(first_pane);
547 let _ = model.update_pane_metadata(
548 second_window_pane,
549 PaneMetadataPatch {
550 title: Some("Claude".into()),
551 cwd: Some("/home/notes/Projects/taskers".into()),
552 repo_name: Some("taskers".into()),
553 git_branch: Some("feature/bootstrap".into()),
554 ports: Some(vec![]),
555 agent_kind: Some("claude".into()),
556 },
557 );
558 let split_pane = model
559 .split_pane(
560 primary_workspace,
561 Some(second_window_pane),
562 SplitAxis::Vertical,
563 )
564 .unwrap_or(second_window_pane);
565 let _ = model.apply_signal(
566 primary_workspace,
567 split_pane,
568 SignalEvent::new(
569 "demo",
570 SignalKind::Progress,
571 Some("Running long task".into()),
572 ),
573 );
574
575 let second_workspace = model.create_workspace("Docs");
576 let second_pane = model
577 .workspaces
578 .get(&second_workspace)
579 .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
580 .unwrap_or_else(PaneId::new);
581 let _ = model.update_pane_metadata(
582 second_pane,
583 PaneMetadataPatch {
584 title: Some("OpenCode".into()),
585 cwd: Some("/home/notes/Documents".into()),
586 repo_name: Some("notes".into()),
587 git_branch: Some("docs".into()),
588 ports: Some(vec![8080, 8081]),
589 agent_kind: Some("opencode".into()),
590 },
591 );
592 let _ = model.apply_signal(
593 second_workspace,
594 second_pane,
595 SignalEvent::new(
596 "demo",
597 SignalKind::Completed,
598 Some("Draft completed, ready for merge".into()),
599 ),
600 );
601 let _ = model.switch_workspace(model.active_window, second_workspace);
602
603 model
604 }
605
606 pub fn active_window(&self) -> Option<&WindowRecord> {
607 self.windows.get(&self.active_window)
608 }
609
610 pub fn active_workspace_id(&self) -> Option<WorkspaceId> {
611 self.active_window().map(|window| window.active_workspace)
612 }
613
614 pub fn active_workspace(&self) -> Option<&Workspace> {
615 self.active_workspace_id()
616 .and_then(|workspace_id| self.workspaces.get(&workspace_id))
617 }
618
619 pub fn create_workspace(&mut self, label: impl Into<String>) -> WorkspaceId {
620 let workspace = Workspace::bootstrap(label);
621 let workspace_id = workspace.id;
622 self.workspaces.insert(workspace_id, workspace);
623 if let Some(window) = self.windows.get_mut(&self.active_window) {
624 window.workspace_order.push(workspace_id);
625 window.active_workspace = workspace_id;
626 }
627 workspace_id
628 }
629
630 pub fn rename_workspace(
631 &mut self,
632 workspace_id: WorkspaceId,
633 label: impl Into<String>,
634 ) -> Result<(), DomainError> {
635 let workspace = self
636 .workspaces
637 .get_mut(&workspace_id)
638 .ok_or(DomainError::MissingWorkspace(workspace_id))?;
639 workspace.label = label.into();
640 Ok(())
641 }
642
643 pub fn switch_workspace(
644 &mut self,
645 window_id: WindowId,
646 workspace_id: WorkspaceId,
647 ) -> Result<(), DomainError> {
648 let window = self
649 .windows
650 .get_mut(&window_id)
651 .ok_or(DomainError::MissingWindow(window_id))?;
652 if !window.workspace_order.contains(&workspace_id) {
653 return Err(DomainError::MissingWorkspace(workspace_id));
654 }
655 window.active_workspace = workspace_id;
656 Ok(())
657 }
658
659 pub fn create_workspace_window(
660 &mut self,
661 workspace_id: WorkspaceId,
662 direction: Direction,
663 ) -> Result<PaneId, DomainError> {
664 let workspace = self
665 .workspaces
666 .get_mut(&workspace_id)
667 .ok_or(DomainError::MissingWorkspace(workspace_id))?;
668
669 let source_frame = workspace
670 .active_window_record()
671 .map(|window| window.frame)
672 .unwrap_or_else(WindowFrame::root);
673 let new_pane = PaneRecord::new(PaneKind::Terminal);
674 let new_pane_id = new_pane.id;
675 workspace.panes.insert(new_pane_id, new_pane);
676
677 let frame = workspace.next_window_frame(source_frame, direction);
678 let new_window = WorkspaceWindowRecord::new(frame, new_pane_id);
679 let new_window_id = new_window.id;
680 workspace.windows.insert(new_window_id, new_window);
681 workspace.sync_active_from_window(new_window_id);
682
683 Ok(new_pane_id)
684 }
685
686 pub fn split_pane(
687 &mut self,
688 workspace_id: WorkspaceId,
689 target_pane: Option<PaneId>,
690 axis: SplitAxis,
691 ) -> Result<PaneId, DomainError> {
692 let workspace = self
693 .workspaces
694 .get_mut(&workspace_id)
695 .ok_or(DomainError::MissingWorkspace(workspace_id))?;
696
697 let target = target_pane.unwrap_or(workspace.active_pane);
698 if !workspace.panes.contains_key(&target) {
699 return Err(DomainError::PaneNotInWorkspace {
700 workspace_id,
701 pane_id: target,
702 });
703 }
704
705 let window_id = workspace
706 .window_for_pane(target)
707 .ok_or(DomainError::MissingPane(target))?;
708 let new_pane = PaneRecord::new(PaneKind::Terminal);
709 let new_pane_id = new_pane.id;
710 workspace.panes.insert(new_pane_id, new_pane);
711
712 if let Some(window) = workspace.windows.get_mut(&window_id) {
713 window.layout.split_leaf(target, axis, new_pane_id, 500);
714 window.active_pane = new_pane_id;
715 }
716 workspace.sync_active_from_window(window_id);
717
718 Ok(new_pane_id)
719 }
720
721 pub fn focus_workspace_window(
722 &mut self,
723 workspace_id: WorkspaceId,
724 workspace_window_id: WorkspaceWindowId,
725 ) -> Result<(), DomainError> {
726 let workspace = self
727 .workspaces
728 .get_mut(&workspace_id)
729 .ok_or(DomainError::MissingWorkspace(workspace_id))?;
730 if !workspace.windows.contains_key(&workspace_window_id) {
731 return Err(DomainError::MissingWorkspaceWindow(workspace_window_id));
732 }
733 workspace.focus_window(workspace_window_id);
734 Ok(())
735 }
736
737 pub fn focus_pane(
738 &mut self,
739 workspace_id: WorkspaceId,
740 pane_id: PaneId,
741 ) -> Result<(), DomainError> {
742 let workspace = self
743 .workspaces
744 .get_mut(&workspace_id)
745 .ok_or(DomainError::MissingWorkspace(workspace_id))?;
746
747 if !workspace.panes.contains_key(&pane_id) {
748 return Err(DomainError::PaneNotInWorkspace {
749 workspace_id,
750 pane_id,
751 });
752 }
753
754 workspace.focus_pane(pane_id);
755 Ok(())
756 }
757
758 pub fn focus_pane_direction(
759 &mut self,
760 workspace_id: WorkspaceId,
761 direction: Direction,
762 ) -> Result<(), DomainError> {
763 let workspace = self
764 .workspaces
765 .get_mut(&workspace_id)
766 .ok_or(DomainError::MissingWorkspace(workspace_id))?;
767 let active_window_id = workspace.active_window;
768
769 if let Some(next_window_id) = workspace.top_level_neighbor(active_window_id, direction) {
770 workspace.focus_window(next_window_id);
771 return Ok(());
772 }
773
774 let next_pane = workspace
775 .windows
776 .get(&active_window_id)
777 .and_then(|window| window.layout.focus_neighbor(window.active_pane, direction));
778 if let Some(next_pane) = next_pane {
779 if let Some(window) = workspace.windows.get_mut(&active_window_id) {
780 window.active_pane = next_pane;
781 }
782 workspace.sync_active_from_window(active_window_id);
783 }
784
785 Ok(())
786 }
787
788 pub fn resize_active_window(
789 &mut self,
790 workspace_id: WorkspaceId,
791 direction: Direction,
792 amount: i32,
793 ) -> Result<(), DomainError> {
794 let workspace = self
795 .workspaces
796 .get_mut(&workspace_id)
797 .ok_or(DomainError::MissingWorkspace(workspace_id))?;
798 let active_window = workspace.active_window;
799 let window = workspace
800 .active_window_record_mut()
801 .ok_or(DomainError::MissingWorkspaceWindow(active_window))?;
802 window.frame.resize_by_direction(direction, amount);
803 window.frame.clamp();
804 Ok(())
805 }
806
807 pub fn resize_active_pane_split(
808 &mut self,
809 workspace_id: WorkspaceId,
810 direction: Direction,
811 amount: i32,
812 ) -> Result<(), DomainError> {
813 let workspace = self
814 .workspaces
815 .get_mut(&workspace_id)
816 .ok_or(DomainError::MissingWorkspace(workspace_id))?;
817 let active_window_id = workspace.active_window;
818 let active_pane = workspace.active_pane;
819 let window = workspace
820 .windows
821 .get_mut(&active_window_id)
822 .ok_or(DomainError::MissingWorkspaceWindow(active_window_id))?;
823 window.layout.resize_leaf(active_pane, direction, amount);
824 Ok(())
825 }
826
827 pub fn set_workspace_window_frame(
828 &mut self,
829 workspace_id: WorkspaceId,
830 workspace_window_id: WorkspaceWindowId,
831 mut frame: WindowFrame,
832 ) -> Result<(), DomainError> {
833 let workspace = self
834 .workspaces
835 .get_mut(&workspace_id)
836 .ok_or(DomainError::MissingWorkspace(workspace_id))?;
837 let window = workspace
838 .windows
839 .get_mut(&workspace_window_id)
840 .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?;
841 frame.clamp();
842 window.frame = frame;
843 Ok(())
844 }
845
846 pub fn set_window_split_ratio(
847 &mut self,
848 workspace_id: WorkspaceId,
849 workspace_window_id: WorkspaceWindowId,
850 path: &[bool],
851 ratio: u16,
852 ) -> Result<(), DomainError> {
853 let workspace = self
854 .workspaces
855 .get_mut(&workspace_id)
856 .ok_or(DomainError::MissingWorkspace(workspace_id))?;
857 let window = workspace
858 .windows
859 .get_mut(&workspace_window_id)
860 .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?;
861 window.layout.set_ratio_at_path(path, ratio);
862 Ok(())
863 }
864
865 pub fn set_workspace_viewport(
866 &mut self,
867 workspace_id: WorkspaceId,
868 viewport: WorkspaceViewport,
869 ) -> Result<(), DomainError> {
870 let workspace = self
871 .workspaces
872 .get_mut(&workspace_id)
873 .ok_or(DomainError::MissingWorkspace(workspace_id))?;
874 workspace.viewport = viewport;
875 Ok(())
876 }
877
878 pub fn update_pane_metadata(
879 &mut self,
880 pane_id: PaneId,
881 patch: PaneMetadataPatch,
882 ) -> Result<(), DomainError> {
883 let pane = self
884 .workspaces
885 .values_mut()
886 .find_map(|workspace| workspace.panes.get_mut(&pane_id))
887 .ok_or(DomainError::MissingPane(pane_id))?;
888
889 if patch.title.is_some() {
890 pane.metadata.title = patch.title;
891 }
892 if patch.cwd.is_some() {
893 pane.metadata.cwd = patch.cwd;
894 }
895 if patch.repo_name.is_some() {
896 pane.metadata.repo_name = patch.repo_name;
897 }
898 if patch.git_branch.is_some() {
899 pane.metadata.git_branch = patch.git_branch;
900 }
901 if let Some(ports) = patch.ports {
902 pane.metadata.ports = ports;
903 }
904 if patch.agent_kind.is_some() {
905 pane.metadata.agent_kind = patch.agent_kind;
906 }
907
908 Ok(())
909 }
910
911 pub fn apply_signal(
912 &mut self,
913 workspace_id: WorkspaceId,
914 pane_id: PaneId,
915 event: SignalEvent,
916 ) -> Result<(), DomainError> {
917 let workspace = self
918 .workspaces
919 .get_mut(&workspace_id)
920 .ok_or(DomainError::MissingWorkspace(workspace_id))?;
921 let pane = workspace
922 .panes
923 .get_mut(&pane_id)
924 .ok_or(DomainError::PaneNotInWorkspace {
925 workspace_id,
926 pane_id,
927 })?;
928
929 pane.metadata.last_signal_at = Some(event.timestamp);
930 pane.attention = map_signal_to_attention(&event.kind);
931
932 if let Some(message) = event.message {
933 workspace.notifications.push(NotificationItem {
934 pane_id,
935 state: pane.attention,
936 message,
937 created_at: event.timestamp,
938 cleared_at: None,
939 });
940 }
941
942 Ok(())
943 }
944
945 pub fn close_pane(
946 &mut self,
947 workspace_id: WorkspaceId,
948 pane_id: PaneId,
949 ) -> Result<(), DomainError> {
950 {
951 let workspace = self
952 .workspaces
953 .get(&workspace_id)
954 .ok_or(DomainError::MissingWorkspace(workspace_id))?;
955
956 if !workspace.panes.contains_key(&pane_id) {
957 return Err(DomainError::PaneNotInWorkspace {
958 workspace_id,
959 pane_id,
960 });
961 }
962
963 if workspace.panes.len() <= 1 {
964 return self.close_workspace(workspace_id);
965 }
966 }
967
968 let workspace = self
969 .workspaces
970 .get_mut(&workspace_id)
971 .ok_or(DomainError::MissingWorkspace(workspace_id))?;
972 let window_id = workspace
973 .window_for_pane(pane_id)
974 .ok_or(DomainError::MissingPane(pane_id))?;
975
976 let window_leaf_count = workspace
977 .windows
978 .get(&window_id)
979 .map(|window| window.layout.leaves().len())
980 .unwrap_or_default();
981 if window_leaf_count <= 1 && workspace.windows.len() > 1 {
982 let source_frame = workspace
983 .windows
984 .get(&window_id)
985 .map(|window| window.frame)
986 .expect("window exists");
987 workspace.windows.shift_remove(&window_id);
988 workspace.panes.shift_remove(&pane_id);
989 workspace
990 .notifications
991 .retain(|item| item.pane_id != pane_id);
992 if let Some(next_window_id) = workspace.fallback_window_after_close(source_frame) {
993 workspace.sync_active_from_window(next_window_id);
994 }
995 return Ok(());
996 }
997
998 if let Some(window) = workspace.windows.get_mut(&window_id) {
999 let fallback_focus = close_layout_pane(window, pane_id)
1000 .or_else(|| window.layout.leaves().into_iter().next())
1001 .expect("window should retain at least one pane");
1002 window.active_pane = fallback_focus;
1003 }
1004 workspace.panes.shift_remove(&pane_id);
1005 workspace
1006 .notifications
1007 .retain(|item| item.pane_id != pane_id);
1008
1009 if workspace.active_window == window_id {
1010 workspace.sync_active_from_window(window_id);
1011 } else if workspace.active_pane == pane_id {
1012 workspace.sync_active_from_window(workspace.active_window);
1013 }
1014
1015 Ok(())
1016 }
1017
1018 pub fn close_workspace(&mut self, workspace_id: WorkspaceId) -> Result<(), DomainError> {
1019 if !self.workspaces.contains_key(&workspace_id) {
1020 return Err(DomainError::MissingWorkspace(workspace_id));
1021 }
1022
1023 if self.workspaces.len() <= 1 {
1024 self.create_workspace("Workspace 1");
1025 }
1026
1027 self.workspaces.shift_remove(&workspace_id);
1028
1029 for window in self.windows.values_mut() {
1030 window.workspace_order.retain(|id| *id != workspace_id);
1031 if window.active_workspace == workspace_id
1032 && let Some(first) = window.workspace_order.first()
1033 {
1034 window.active_workspace = *first;
1035 }
1036 }
1037
1038 Ok(())
1039 }
1040
1041 pub fn workspace_summaries(
1042 &self,
1043 window_id: WindowId,
1044 ) -> Result<Vec<WorkspaceSummary>, DomainError> {
1045 let window = self
1046 .windows
1047 .get(&window_id)
1048 .ok_or(DomainError::MissingWindow(window_id))?;
1049
1050 let summaries = window
1051 .workspace_order
1052 .iter()
1053 .filter_map(|workspace_id| self.workspaces.get(workspace_id))
1054 .map(|workspace| {
1055 let counts = workspace.attention_counts();
1056 let highest_attention = workspace
1057 .panes
1058 .values()
1059 .map(|pane| pane.attention)
1060 .max_by_key(|attention| attention.rank())
1061 .unwrap_or(AttentionState::Normal);
1062
1063 WorkspaceSummary {
1064 workspace_id: workspace.id,
1065 label: workspace.label.clone(),
1066 active_pane: workspace.active_pane,
1067 repo_hint: workspace.repo_hint().map(str::to_owned),
1068 counts_by_attention: counts,
1069 highest_attention,
1070 }
1071 })
1072 .collect();
1073
1074 Ok(summaries)
1075 }
1076
1077 pub fn activity_items(&self) -> Vec<ActivityItem> {
1078 let mut items = self
1079 .workspaces
1080 .values()
1081 .flat_map(|workspace| {
1082 workspace
1083 .notifications
1084 .iter()
1085 .map(move |notification| ActivityItem {
1086 workspace_id: workspace.id,
1087 pane_id: notification.pane_id,
1088 state: notification.state,
1089 message: notification.message.clone(),
1090 created_at: notification.created_at,
1091 })
1092 })
1093 .collect::<Vec<_>>();
1094
1095 items.sort_by(|left, right| right.created_at.cmp(&left.created_at));
1096 items
1097 }
1098
1099 pub fn snapshot(&self) -> PersistedSession {
1100 PersistedSession {
1101 schema_version: SESSION_SCHEMA_VERSION,
1102 captured_at: OffsetDateTime::now_utc(),
1103 model: self.clone(),
1104 }
1105 }
1106}
1107
1108#[derive(Debug, Deserialize)]
1109#[serde(untagged)]
1110enum WorkspaceSerdeCompat {
1111 Current(CurrentWorkspaceSerde),
1112 Legacy(LegacyWorkspaceSerde),
1113}
1114
1115#[derive(Debug, Deserialize)]
1116struct CurrentWorkspaceSerde {
1117 id: WorkspaceId,
1118 label: String,
1119 windows: IndexMap<WorkspaceWindowId, WorkspaceWindowRecord>,
1120 active_window: WorkspaceWindowId,
1121 panes: IndexMap<PaneId, PaneRecord>,
1122 active_pane: PaneId,
1123 #[serde(default)]
1124 viewport: WorkspaceViewport,
1125 #[serde(default)]
1126 notifications: Vec<NotificationItem>,
1127}
1128
1129impl CurrentWorkspaceSerde {
1130 fn into_workspace(self) -> Workspace {
1131 let mut workspace = Workspace {
1132 id: self.id,
1133 label: self.label,
1134 windows: self.windows,
1135 active_window: self.active_window,
1136 panes: self.panes,
1137 active_pane: self.active_pane,
1138 viewport: self.viewport,
1139 notifications: self.notifications,
1140 };
1141 workspace.normalize();
1142 workspace
1143 }
1144}
1145
1146#[derive(Debug, Deserialize)]
1147struct LegacyWorkspaceSerde {
1148 id: WorkspaceId,
1149 label: String,
1150 layout: LegacyWorkspaceLayout,
1151 panes: IndexMap<PaneId, PaneRecord>,
1152 active_pane: PaneId,
1153 #[serde(default)]
1154 notifications: Vec<NotificationItem>,
1155}
1156
1157impl LegacyWorkspaceSerde {
1158 fn into_workspace(self) -> Workspace {
1159 let mut windows = IndexMap::new();
1160 let mut viewport = WorkspaceViewport::default();
1161 let mut active_window = None;
1162 let preferred_active_pane = if self.panes.contains_key(&self.active_pane) {
1163 self.active_pane
1164 } else {
1165 self.panes
1166 .first()
1167 .map(|(pane_id, _)| *pane_id)
1168 .unwrap_or_else(PaneId::new)
1169 };
1170
1171 match self.layout {
1172 LegacyWorkspaceLayout::SplitTree(layout) => {
1173 let active_pane = active_pane_for_layout(&layout, preferred_active_pane);
1174 let window = WorkspaceWindowRecord {
1175 id: WorkspaceWindowId::new(),
1176 frame: WindowFrame::root(),
1177 layout,
1178 active_pane,
1179 };
1180 active_window = Some(window.id);
1181 windows.insert(window.id, window);
1182 }
1183 LegacyWorkspaceLayout::Scrollable(scrollable) => {
1184 viewport = scrollable.viewport;
1185 for (index, column) in scrollable.columns.into_iter().enumerate() {
1186 let Some(layout) = layout_from_pane_stack(&column.panes) else {
1187 continue;
1188 };
1189 let active_pane = active_pane_for_layout(&layout, preferred_active_pane);
1190 let frame = WindowFrame {
1191 x: index as i32
1192 * (DEFAULT_WORKSPACE_WINDOW_WIDTH + DEFAULT_WORKSPACE_WINDOW_GAP),
1193 y: 0,
1194 width: DEFAULT_WORKSPACE_WINDOW_WIDTH,
1195 height: DEFAULT_WORKSPACE_WINDOW_HEIGHT,
1196 };
1197 let window = WorkspaceWindowRecord {
1198 id: WorkspaceWindowId::new(),
1199 frame,
1200 layout,
1201 active_pane,
1202 };
1203 if window.layout.contains(preferred_active_pane) {
1204 active_window = Some(window.id);
1205 }
1206 windows.insert(window.id, window);
1207 }
1208 }
1209 }
1210
1211 let mut workspace = Workspace {
1212 id: self.id,
1213 label: self.label,
1214 windows,
1215 active_window: active_window.unwrap_or_else(WorkspaceWindowId::new),
1216 panes: self.panes,
1217 active_pane: preferred_active_pane,
1218 viewport,
1219 notifications: self.notifications,
1220 };
1221 workspace.normalize();
1222 workspace
1223 }
1224}
1225
1226#[derive(Debug, Deserialize)]
1227#[serde(untagged)]
1228enum LegacyWorkspaceLayout {
1229 Scrollable(LegacyScrollableLayout),
1230 SplitTree(LayoutNode),
1231}
1232
1233#[derive(Debug, Deserialize)]
1234struct LegacyScrollableLayout {
1235 #[serde(rename = "kind")]
1236 _kind: String,
1237 columns: Vec<LegacyPaneColumn>,
1238 #[serde(default)]
1239 viewport: WorkspaceViewport,
1240}
1241
1242#[derive(Debug, Deserialize)]
1243struct LegacyPaneColumn {
1244 panes: Vec<PaneId>,
1245}
1246
1247fn active_pane_for_layout(layout: &LayoutNode, preferred: PaneId) -> PaneId {
1248 if layout.contains(preferred) {
1249 preferred
1250 } else {
1251 layout
1252 .leaves()
1253 .into_iter()
1254 .next()
1255 .expect("legacy layout should contain at least one pane")
1256 }
1257}
1258
1259fn layout_from_pane_stack(panes: &[PaneId]) -> Option<LayoutNode> {
1260 let (first, rest) = panes.split_first()?;
1261 let mut layout = LayoutNode::leaf(*first);
1262 for pane_id in rest {
1263 layout = LayoutNode::Split {
1264 axis: SplitAxis::Vertical,
1265 ratio: 500,
1266 first: Box::new(layout),
1267 second: Box::new(LayoutNode::leaf(*pane_id)),
1268 };
1269 }
1270 Some(layout)
1271}
1272
1273fn close_layout_pane(window: &mut WorkspaceWindowRecord, pane_id: PaneId) -> Option<PaneId> {
1274 let fallback = [
1275 Direction::Right,
1276 Direction::Down,
1277 Direction::Left,
1278 Direction::Up,
1279 ]
1280 .into_iter()
1281 .find_map(|direction| window.layout.focus_neighbor(pane_id, direction))
1282 .or_else(|| {
1283 window
1284 .layout
1285 .leaves()
1286 .into_iter()
1287 .find(|candidate| *candidate != pane_id)
1288 });
1289 let removed = window.layout.remove_leaf(pane_id);
1290 removed.then_some(fallback).flatten()
1291}
1292
1293fn map_signal_to_attention(kind: &SignalKind) -> AttentionState {
1294 match kind {
1295 SignalKind::Started | SignalKind::Progress => AttentionState::Busy,
1296 SignalKind::Completed => AttentionState::Completed,
1297 SignalKind::WaitingInput => AttentionState::WaitingInput,
1298 SignalKind::Error => AttentionState::Error,
1299 SignalKind::Notification => AttentionState::Busy,
1300 }
1301}
1302
1303#[cfg(test)]
1304mod tests {
1305 use serde_json::json;
1306
1307 use super::*;
1308
1309 #[test]
1310 fn creating_workspace_windows_updates_focus_and_frame() {
1311 let mut model = AppModel::new("Main");
1312 let workspace_id = model.active_workspace_id().expect("workspace");
1313 let first_window = model
1314 .active_workspace()
1315 .and_then(|workspace| workspace.active_window_record().map(|window| window.frame))
1316 .expect("window");
1317
1318 let new_pane = model
1319 .create_workspace_window(workspace_id, Direction::Right)
1320 .expect("window created");
1321 let workspace = model.workspaces.get(&workspace_id).expect("workspace");
1322 let active_window = workspace.active_window_record().expect("active window");
1323
1324 assert_eq!(workspace.windows.len(), 2);
1325 assert_eq!(workspace.active_pane, new_pane);
1326 assert_eq!(
1327 active_window.frame.x,
1328 first_window.x + first_window.width + DEFAULT_WORKSPACE_WINDOW_GAP
1329 );
1330 assert_eq!(active_window.frame.y, first_window.y);
1331 }
1332
1333 #[test]
1334 fn split_pane_updates_inner_layout_and_focus() {
1335 let mut model = AppModel::new("Main");
1336 let workspace_id = model.active_workspace_id().expect("workspace");
1337 let first_pane = model
1338 .active_workspace()
1339 .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
1340 .expect("pane");
1341
1342 let new_pane = model
1343 .split_pane(workspace_id, Some(first_pane), SplitAxis::Vertical)
1344 .expect("split works");
1345 let workspace = model.workspaces.get(&workspace_id).expect("workspace");
1346 let active_window = workspace.active_window_record().expect("window");
1347
1348 assert_eq!(workspace.active_pane, new_pane);
1349 assert_eq!(active_window.layout.leaves(), vec![first_pane, new_pane]);
1350 }
1351
1352 #[test]
1353 fn directional_focus_prefers_top_level_windows_and_restores_inner_focus() {
1354 let mut model = AppModel::new("Main");
1355 let workspace_id = model.active_workspace_id().expect("workspace");
1356 let first_pane = model
1357 .active_workspace()
1358 .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
1359 .expect("pane");
1360 let right_window_pane = model
1361 .create_workspace_window(workspace_id, Direction::Right)
1362 .expect("window");
1363 let lower_right_pane = model
1364 .split_pane(workspace_id, Some(right_window_pane), SplitAxis::Vertical)
1365 .expect("split");
1366
1367 model
1368 .focus_pane(workspace_id, right_window_pane)
1369 .expect("focus old pane in right window");
1370 model
1371 .focus_pane(workspace_id, first_pane)
1372 .expect("focus left window");
1373 model
1374 .focus_pane_direction(workspace_id, Direction::Right)
1375 .expect("move right");
1376
1377 assert_eq!(
1378 model
1379 .workspaces
1380 .get(&workspace_id)
1381 .expect("workspace")
1382 .active_pane,
1383 right_window_pane
1384 );
1385
1386 model
1387 .focus_pane(workspace_id, lower_right_pane)
1388 .expect("focus lower pane");
1389 model
1390 .focus_pane_direction(workspace_id, Direction::Left)
1391 .expect("move left");
1392 model
1393 .focus_pane_direction(workspace_id, Direction::Right)
1394 .expect("move right again");
1395
1396 assert_eq!(
1397 model
1398 .workspaces
1399 .get(&workspace_id)
1400 .expect("workspace")
1401 .active_pane,
1402 lower_right_pane
1403 );
1404 }
1405
1406 #[test]
1407 fn closing_last_pane_in_window_removes_window_and_falls_back() {
1408 let mut model = AppModel::new("Main");
1409 let workspace_id = model.active_workspace_id().expect("workspace");
1410 let right_window_pane = model
1411 .create_workspace_window(workspace_id, Direction::Right)
1412 .expect("window");
1413
1414 model
1415 .close_pane(workspace_id, right_window_pane)
1416 .expect("close pane");
1417
1418 let workspace = model.workspaces.get(&workspace_id).expect("workspace");
1419 assert_eq!(workspace.windows.len(), 1);
1420 assert!(!workspace.panes.contains_key(&right_window_pane));
1421 assert_ne!(workspace.active_pane, right_window_pane);
1422 }
1423
1424 #[test]
1425 fn resizing_window_and_split_updates_state() {
1426 let mut model = AppModel::new("Main");
1427 let workspace_id = model.active_workspace_id().expect("workspace");
1428 let first_pane = model
1429 .active_workspace()
1430 .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
1431 .expect("pane");
1432 let second_pane = model
1433 .split_pane(workspace_id, Some(first_pane), SplitAxis::Horizontal)
1434 .expect("split");
1435
1436 model
1437 .focus_pane(workspace_id, second_pane)
1438 .expect("focus second pane");
1439 model
1440 .resize_active_pane_split(workspace_id, Direction::Right, 60)
1441 .expect("resize split");
1442 model
1443 .resize_active_window(workspace_id, Direction::Right, 120)
1444 .expect("resize window");
1445
1446 let workspace = model.workspaces.get(&workspace_id).expect("workspace");
1447 let window = workspace.active_window_record().expect("window");
1448 let LayoutNode::Split { ratio, .. } = &window.layout else {
1449 panic!("expected split layout");
1450 };
1451 assert_eq!(*ratio, 440);
1452 assert_eq!(window.frame.width, DEFAULT_WORKSPACE_WINDOW_WIDTH + 120);
1453 }
1454
1455 #[test]
1456 fn legacy_scrollable_layouts_deserialize_into_workspace_windows() {
1457 let workspace_id = WorkspaceId::new();
1458 let window_id = WindowId::new();
1459 let left_pane = PaneRecord::new(PaneKind::Terminal);
1460 let right_pane = PaneRecord::new(PaneKind::Terminal);
1461
1462 let encoded = json!({
1463 "schema_version": 1,
1464 "captured_at": OffsetDateTime::now_utc(),
1465 "model": {
1466 "active_window": window_id,
1467 "windows": {
1468 window_id.to_string(): {
1469 "id": window_id,
1470 "workspace_order": [workspace_id],
1471 "active_workspace": workspace_id
1472 }
1473 },
1474 "workspaces": {
1475 workspace_id.to_string(): {
1476 "id": workspace_id,
1477 "label": "Main",
1478 "layout": {
1479 "kind": "scrollable_tiling",
1480 "columns": [
1481 {"panes": [left_pane.id]},
1482 {"panes": [right_pane.id]}
1483 ],
1484 "viewport": {"x": 64, "y": 24}
1485 },
1486 "panes": {
1487 left_pane.id.to_string(): left_pane,
1488 right_pane.id.to_string(): right_pane
1489 },
1490 "active_pane": right_pane.id,
1491 "notifications": []
1492 }
1493 }
1494 }
1495 });
1496
1497 let decoded: PersistedSession =
1498 serde_json::from_value(encoded).expect("legacy session should deserialize");
1499 let workspace = decoded
1500 .model
1501 .workspaces
1502 .get(&workspace_id)
1503 .expect("workspace exists");
1504
1505 assert_eq!(workspace.windows.len(), 2);
1506 assert_eq!(workspace.viewport.x, 64);
1507 assert_eq!(workspace.viewport.y, 24);
1508 assert_eq!(workspace.active_pane, right_pane.id);
1509 }
1510
1511 #[test]
1512 fn signals_flow_into_activity_and_summary() {
1513 let mut model = AppModel::new("Main");
1514 let workspace_id = model.active_workspace_id().expect("workspace");
1515 let pane_id = model
1516 .active_workspace()
1517 .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
1518 .expect("pane");
1519
1520 model
1521 .apply_signal(
1522 workspace_id,
1523 pane_id,
1524 SignalEvent::new(
1525 "test",
1526 SignalKind::WaitingInput,
1527 Some("Need approval".into()),
1528 ),
1529 )
1530 .expect("signal applied");
1531
1532 let summaries = model
1533 .workspace_summaries(model.active_window)
1534 .expect("summary available");
1535 let summary = summaries.first().expect("summary");
1536
1537 assert_eq!(summary.highest_attention, AttentionState::WaitingInput);
1538 assert_eq!(
1539 summary
1540 .counts_by_attention
1541 .get(&AttentionState::WaitingInput)
1542 .copied(),
1543 Some(1)
1544 );
1545 assert_eq!(model.activity_items().len(), 1);
1546 }
1547
1548 #[test]
1549 fn persisted_session_roundtrips() {
1550 let model = AppModel::demo();
1551 let snapshot = model.snapshot();
1552 let encoded = serde_json::to_string_pretty(&snapshot).expect("serialize");
1553 let decoded: PersistedSession = serde_json::from_str(&encoded).expect("deserialize");
1554
1555 assert_eq!(decoded.schema_version, SESSION_SCHEMA_VERSION);
1556 assert_eq!(decoded.model.workspaces.len(), model.workspaces.len());
1557 }
1558}