1use std::sync::{Arc, Mutex};
2
3use taskers_domain::{
4 AppModel, BrowserProfileMode, DomainError, PaneId, PaneKind, SurfaceId, SurfaceRecord,
5 WindowId, WorkspaceId,
6};
7
8use crate::protocol::{
9 ControlCommand, ControlQuery, ControlResponse, IdentifyContext, IdentifyResult,
10};
11
12#[derive(Debug, Clone)]
13pub struct InMemoryController {
14 state: Arc<Mutex<ControllerState>>,
15}
16
17#[derive(Debug, Clone)]
18struct ControllerState {
19 model: AppModel,
20 revision: u64,
21}
22
23#[derive(Debug, Clone)]
24pub struct ControllerSnapshot {
25 pub model: AppModel,
26 pub revision: u64,
27}
28
29impl InMemoryController {
30 pub fn new(state: AppModel) -> Self {
31 Self {
32 state: Arc::new(Mutex::new(ControllerState {
33 model: state,
34 revision: 0,
35 })),
36 }
37 }
38
39 pub fn snapshot(&self) -> ControllerSnapshot {
40 let state = self.state.lock().expect("state mutex poisoned").clone();
41 ControllerSnapshot {
42 model: state.model,
43 revision: state.revision,
44 }
45 }
46
47 pub fn revision(&self) -> u64 {
48 self.state.lock().expect("state mutex poisoned").revision
49 }
50
51 pub fn handle(&self, command: ControlCommand) -> Result<ControlResponse, DomainError> {
52 let mut state = self.state.lock().expect("state mutex poisoned");
53 let model = &mut state.model;
54
55 let (response, mutated) = match command {
56 ControlCommand::CreateWorkspace { label } => {
57 let workspace_id = model.create_workspace(label);
58 (ControlResponse::WorkspaceCreated { workspace_id }, true)
59 }
60 ControlCommand::RenameWorkspace {
61 workspace_id,
62 label,
63 } => {
64 model.rename_workspace(workspace_id, label)?;
65 (
66 ControlResponse::Ack {
67 message: "workspace renamed".into(),
68 },
69 true,
70 )
71 }
72 ControlCommand::SwitchWorkspace {
73 window_id,
74 workspace_id,
75 } => {
76 let target_window = window_id.unwrap_or(model.active_window);
77 model.switch_workspace(target_window, workspace_id)?;
78 (
79 ControlResponse::Ack {
80 message: "workspace switched".into(),
81 },
82 true,
83 )
84 }
85 ControlCommand::SplitPane {
86 workspace_id,
87 pane_id,
88 axis,
89 } => {
90 let new_pane_id = model.split_pane(workspace_id, pane_id, axis)?;
91 (
92 ControlResponse::PaneSplit {
93 pane_id: new_pane_id,
94 },
95 true,
96 )
97 }
98 ControlCommand::SplitPaneDirection {
99 workspace_id,
100 pane_id,
101 direction,
102 } => {
103 let new_pane_id =
104 model.split_pane_direction(workspace_id, Some(pane_id), direction)?;
105 (
106 ControlResponse::PaneSplit {
107 pane_id: new_pane_id,
108 },
109 true,
110 )
111 }
112 ControlCommand::CreateWorkspaceWindow {
113 workspace_id,
114 direction,
115 } => {
116 let new_pane_id = model.create_workspace_window(workspace_id, direction)?;
117 (
118 ControlResponse::WorkspaceWindowCreated {
119 pane_id: new_pane_id,
120 },
121 true,
122 )
123 }
124 ControlCommand::FocusWorkspaceWindow {
125 workspace_id,
126 workspace_window_id,
127 } => {
128 model.focus_workspace_window(workspace_id, workspace_window_id)?;
129 (
130 ControlResponse::Ack {
131 message: "workspace window focused".into(),
132 },
133 true,
134 )
135 }
136 ControlCommand::MoveWorkspaceWindow {
137 workspace_id,
138 workspace_window_id,
139 target,
140 } => {
141 model.move_workspace_window(workspace_id, workspace_window_id, target)?;
142 (
143 ControlResponse::Ack {
144 message: "workspace window moved".into(),
145 },
146 true,
147 )
148 }
149 ControlCommand::CreateWorkspaceWindowTab {
150 workspace_id,
151 workspace_window_id,
152 } => {
153 let (workspace_window_tab_id, pane_id) =
154 model.create_workspace_window_tab(workspace_id, workspace_window_id)?;
155 (
156 ControlResponse::WorkspaceWindowTabCreated {
157 pane_id,
158 workspace_window_tab_id,
159 },
160 true,
161 )
162 }
163 ControlCommand::FocusWorkspaceWindowTab {
164 workspace_id,
165 workspace_window_id,
166 workspace_window_tab_id,
167 } => {
168 model.focus_workspace_window_tab(
169 workspace_id,
170 workspace_window_id,
171 workspace_window_tab_id,
172 )?;
173 (
174 ControlResponse::Ack {
175 message: "workspace window tab focused".into(),
176 },
177 true,
178 )
179 }
180 ControlCommand::MoveWorkspaceWindowTab {
181 workspace_id,
182 workspace_window_id,
183 workspace_window_tab_id,
184 to_index,
185 } => {
186 model.move_workspace_window_tab(
187 workspace_id,
188 workspace_window_id,
189 workspace_window_tab_id,
190 to_index,
191 )?;
192 (
193 ControlResponse::Ack {
194 message: "workspace window tab moved".into(),
195 },
196 true,
197 )
198 }
199 ControlCommand::TransferWorkspaceWindowTab {
200 workspace_id,
201 source_workspace_window_id,
202 workspace_window_tab_id,
203 target_workspace_window_id,
204 to_index,
205 } => {
206 model.transfer_workspace_window_tab(
207 workspace_id,
208 source_workspace_window_id,
209 workspace_window_tab_id,
210 target_workspace_window_id,
211 to_index,
212 )?;
213 (
214 ControlResponse::Ack {
215 message: "workspace window tab transferred".into(),
216 },
217 true,
218 )
219 }
220 ControlCommand::ExtractWorkspaceWindowTab {
221 workspace_id,
222 source_workspace_window_id,
223 workspace_window_tab_id,
224 target,
225 } => {
226 model.extract_workspace_window_tab(
227 workspace_id,
228 source_workspace_window_id,
229 workspace_window_tab_id,
230 target,
231 )?;
232 (
233 ControlResponse::Ack {
234 message: "workspace window tab extracted".into(),
235 },
236 true,
237 )
238 }
239 ControlCommand::CloseWorkspaceWindowTab {
240 workspace_id,
241 workspace_window_id,
242 workspace_window_tab_id,
243 } => {
244 model.close_workspace_window_tab(
245 workspace_id,
246 workspace_window_id,
247 workspace_window_tab_id,
248 )?;
249 (
250 ControlResponse::Ack {
251 message: "workspace window tab closed".into(),
252 },
253 true,
254 )
255 }
256 ControlCommand::CreatePaneTab {
257 workspace_id,
258 pane_container_id,
259 kind,
260 } => {
261 let (pane_tab_id, pane_id) =
262 model.create_pane_tab(workspace_id, pane_container_id, kind)?;
263 (
264 ControlResponse::PaneTabCreated {
265 pane_id,
266 pane_tab_id,
267 },
268 true,
269 )
270 }
271 ControlCommand::FocusPaneTab {
272 workspace_id,
273 pane_container_id,
274 pane_tab_id,
275 } => {
276 model.focus_pane_tab(workspace_id, pane_container_id, pane_tab_id)?;
277 (
278 ControlResponse::Ack {
279 message: "pane tab focused".into(),
280 },
281 true,
282 )
283 }
284 ControlCommand::MovePaneTab {
285 workspace_id,
286 pane_container_id,
287 pane_tab_id,
288 to_index,
289 } => {
290 model.move_pane_tab(workspace_id, pane_container_id, pane_tab_id, to_index)?;
291 (
292 ControlResponse::Ack {
293 message: "pane tab moved".into(),
294 },
295 true,
296 )
297 }
298 ControlCommand::TransferPaneTab {
299 workspace_id,
300 source_pane_container_id,
301 pane_tab_id,
302 target_pane_container_id,
303 to_index,
304 } => {
305 model.transfer_pane_tab(
306 workspace_id,
307 source_pane_container_id,
308 pane_tab_id,
309 target_pane_container_id,
310 to_index,
311 )?;
312 (
313 ControlResponse::Ack {
314 message: "pane tab transferred".into(),
315 },
316 true,
317 )
318 }
319 ControlCommand::ClosePaneTab {
320 workspace_id,
321 pane_container_id,
322 pane_tab_id,
323 } => {
324 model.close_pane_tab(workspace_id, pane_container_id, pane_tab_id)?;
325 (
326 ControlResponse::Ack {
327 message: "pane tab closed".into(),
328 },
329 true,
330 )
331 }
332 ControlCommand::FocusPane {
333 workspace_id,
334 pane_id,
335 } => {
336 model.focus_pane(workspace_id, pane_id)?;
337 (
338 ControlResponse::Ack {
339 message: "pane focused".into(),
340 },
341 true,
342 )
343 }
344 ControlCommand::FocusPaneDirection {
345 workspace_id,
346 direction,
347 } => {
348 model.focus_pane_direction(workspace_id, direction)?;
349 (
350 ControlResponse::Ack {
351 message: "pane focus moved".into(),
352 },
353 true,
354 )
355 }
356 ControlCommand::ResizeActiveWindow {
357 workspace_id,
358 direction,
359 amount,
360 } => {
361 model.resize_active_window(workspace_id, direction, amount)?;
362 (
363 ControlResponse::Ack {
364 message: "workspace window resized".into(),
365 },
366 true,
367 )
368 }
369 ControlCommand::ResizeActivePaneSplit {
370 workspace_id,
371 direction,
372 amount,
373 } => {
374 model.resize_active_pane_split(workspace_id, direction, amount)?;
375 (
376 ControlResponse::Ack {
377 message: "pane split resized".into(),
378 },
379 true,
380 )
381 }
382 ControlCommand::SetWorkspaceColumnWidth {
383 workspace_id,
384 workspace_column_id,
385 width,
386 } => {
387 model.set_workspace_column_width(workspace_id, workspace_column_id, width)?;
388 (
389 ControlResponse::Ack {
390 message: "workspace column width updated".into(),
391 },
392 true,
393 )
394 }
395 ControlCommand::SetWorkspaceWindowHeight {
396 workspace_id,
397 workspace_window_id,
398 height,
399 } => {
400 model.set_workspace_window_height(workspace_id, workspace_window_id, height)?;
401 (
402 ControlResponse::Ack {
403 message: "workspace window height updated".into(),
404 },
405 true,
406 )
407 }
408 ControlCommand::SetWindowSplitRatio {
409 workspace_id,
410 workspace_window_id,
411 path,
412 ratio,
413 } => {
414 model.set_window_split_ratio(workspace_id, workspace_window_id, &path, ratio)?;
415 (
416 ControlResponse::Ack {
417 message: "window split ratio updated".into(),
418 },
419 true,
420 )
421 }
422 ControlCommand::SetPaneTabSplitRatio {
423 workspace_id,
424 pane_container_id,
425 pane_tab_id,
426 path,
427 ratio,
428 } => {
429 model.set_pane_tab_split_ratio(
430 workspace_id,
431 pane_container_id,
432 pane_tab_id,
433 &path,
434 ratio,
435 )?;
436 (
437 ControlResponse::Ack {
438 message: "pane tab split ratio updated".into(),
439 },
440 true,
441 )
442 }
443 ControlCommand::UpdatePaneMetadata { pane_id, patch } => {
444 model.update_pane_metadata(pane_id, patch)?;
445 (
446 ControlResponse::Ack {
447 message: "pane metadata updated".into(),
448 },
449 true,
450 )
451 }
452 ControlCommand::UpdateSurfaceMetadata { surface_id, patch } => {
453 model.update_surface_metadata(surface_id, patch)?;
454 (
455 ControlResponse::Ack {
456 message: "surface metadata updated".into(),
457 },
458 true,
459 )
460 }
461 ControlCommand::CreateSurface {
462 workspace_id,
463 pane_id,
464 kind,
465 browser_profile_mode,
466 } => {
467 let is_browser = matches!(kind, PaneKind::Browser);
468 let surface_id = model.create_surface(workspace_id, pane_id, kind)?;
469 if is_browser {
470 model.update_surface_metadata(
471 surface_id,
472 taskers_domain::PaneMetadataPatch {
473 browser_profile_mode: Some(
474 browser_profile_mode
475 .unwrap_or(BrowserProfileMode::PersistentDefault),
476 ),
477 ..taskers_domain::PaneMetadataPatch::default()
478 },
479 )?;
480 }
481 (ControlResponse::SurfaceCreated { surface_id }, true)
482 }
483 ControlCommand::FocusSurface {
484 workspace_id,
485 pane_id,
486 surface_id,
487 } => {
488 model.focus_surface(workspace_id, pane_id, surface_id)?;
489 (
490 ControlResponse::Ack {
491 message: "surface focused".into(),
492 },
493 true,
494 )
495 }
496 ControlCommand::StartSurfaceAgentSession {
497 workspace_id: _,
498 pane_id: _,
499 surface_id,
500 agent_kind,
501 } => {
502 let current = resolve_identify_context(model, None, None, Some(surface_id))?;
503 model.start_surface_agent_session(
504 current.workspace_id,
505 current.pane_id,
506 surface_id,
507 agent_kind,
508 )?;
509 (
510 ControlResponse::Ack {
511 message: "surface agent session started".into(),
512 },
513 true,
514 )
515 }
516 ControlCommand::StopSurfaceAgentSession {
517 workspace_id: _,
518 pane_id: _,
519 surface_id,
520 exit_status,
521 } => {
522 let current = resolve_identify_context(model, None, None, Some(surface_id))?;
523 model.stop_surface_agent_session(
524 current.workspace_id,
525 current.pane_id,
526 surface_id,
527 exit_status,
528 )?;
529 (
530 ControlResponse::Ack {
531 message: "surface agent session stopped".into(),
532 },
533 true,
534 )
535 }
536 ControlCommand::MarkSurfaceCompleted {
537 workspace_id,
538 pane_id,
539 surface_id,
540 } => {
541 model.mark_surface_completed(workspace_id, pane_id, surface_id)?;
542 (
543 ControlResponse::Ack {
544 message: "surface marked completed".into(),
545 },
546 true,
547 )
548 }
549 ControlCommand::CloseSurface {
550 workspace_id,
551 pane_id,
552 surface_id,
553 } => {
554 model.close_surface(workspace_id, pane_id, surface_id)?;
555 (
556 ControlResponse::Ack {
557 message: "surface closed".into(),
558 },
559 true,
560 )
561 }
562 ControlCommand::MoveSurface {
563 workspace_id,
564 pane_id,
565 surface_id,
566 to_index,
567 } => {
568 model.move_surface(workspace_id, pane_id, surface_id, to_index)?;
569 (
570 ControlResponse::Ack {
571 message: "surface moved".into(),
572 },
573 true,
574 )
575 }
576 ControlCommand::TransferSurface {
577 source_workspace_id,
578 source_pane_id,
579 surface_id,
580 target_workspace_id,
581 target_pane_id,
582 to_index,
583 } => {
584 model.transfer_surface(
585 source_workspace_id,
586 source_pane_id,
587 surface_id,
588 target_workspace_id,
589 target_pane_id,
590 to_index,
591 )?;
592 (
593 ControlResponse::Ack {
594 message: "surface transferred".into(),
595 },
596 true,
597 )
598 }
599 ControlCommand::MoveSurfaceToSplit {
600 source_workspace_id,
601 source_pane_id,
602 surface_id,
603 target_workspace_id,
604 target_pane_id,
605 direction,
606 } => {
607 let new_pane_id = model.move_surface_to_split(
608 source_workspace_id,
609 source_pane_id,
610 surface_id,
611 target_workspace_id,
612 target_pane_id,
613 direction,
614 )?;
615 (
616 ControlResponse::SurfaceMovedToSplit {
617 pane_id: new_pane_id,
618 },
619 true,
620 )
621 }
622 ControlCommand::MoveSurfaceToWorkspace {
623 source_workspace_id,
624 source_pane_id,
625 surface_id,
626 target_workspace_id,
627 } => {
628 let new_pane_id = model.move_surface_to_workspace(
629 source_workspace_id,
630 source_pane_id,
631 surface_id,
632 target_workspace_id,
633 )?;
634 (
635 ControlResponse::SurfaceMovedToWorkspace {
636 pane_id: new_pane_id,
637 },
638 true,
639 )
640 }
641 ControlCommand::SetWorkspaceViewport {
642 workspace_id,
643 viewport,
644 } => {
645 model.set_workspace_viewport(workspace_id, viewport)?;
646 (
647 ControlResponse::Ack {
648 message: "workspace viewport updated".into(),
649 },
650 true,
651 )
652 }
653 ControlCommand::ClosePane {
654 workspace_id,
655 pane_id,
656 } => {
657 model.close_pane(workspace_id, pane_id)?;
658 (
659 ControlResponse::Ack {
660 message: "pane closed".into(),
661 },
662 true,
663 )
664 }
665 ControlCommand::CloseWorkspace { workspace_id } => {
666 model.close_workspace(workspace_id)?;
667 (
668 ControlResponse::Ack {
669 message: "workspace closed".into(),
670 },
671 true,
672 )
673 }
674 ControlCommand::ReorderWorkspaces {
675 window_id,
676 workspace_ids,
677 } => {
678 model.reorder_workspaces(window_id, workspace_ids)?;
679 (
680 ControlResponse::Ack {
681 message: "workspaces reordered".into(),
682 },
683 true,
684 )
685 }
686 ControlCommand::EmitSignal {
687 workspace_id,
688 pane_id,
689 surface_id,
690 event,
691 } => {
692 if let Some(surface_id) = surface_id {
693 let current = resolve_identify_context(model, None, None, Some(surface_id))?;
694 model.apply_surface_signal(
695 current.workspace_id,
696 current.pane_id,
697 surface_id,
698 event,
699 )?;
700 } else {
701 model.apply_signal(workspace_id, pane_id, event)?;
702 }
703 (
704 ControlResponse::Ack {
705 message: "signal applied".into(),
706 },
707 true,
708 )
709 }
710 ControlCommand::AgentSetStatus { workspace_id, text } => {
711 model.set_workspace_status(workspace_id, text)?;
712 (
713 ControlResponse::Ack {
714 message: "workspace agent status updated".into(),
715 },
716 true,
717 )
718 }
719 ControlCommand::AgentClearStatus { workspace_id } => {
720 model.clear_workspace_status(workspace_id)?;
721 (
722 ControlResponse::Ack {
723 message: "workspace agent status cleared".into(),
724 },
725 true,
726 )
727 }
728 ControlCommand::AgentSetProgress {
729 workspace_id,
730 progress,
731 } => {
732 model.set_workspace_progress(workspace_id, progress)?;
733 (
734 ControlResponse::Ack {
735 message: "workspace progress updated".into(),
736 },
737 true,
738 )
739 }
740 ControlCommand::AgentClearProgress { workspace_id } => {
741 model.clear_workspace_progress(workspace_id)?;
742 (
743 ControlResponse::Ack {
744 message: "workspace progress cleared".into(),
745 },
746 true,
747 )
748 }
749 ControlCommand::AgentAppendLog {
750 workspace_id,
751 entry,
752 } => {
753 model.append_workspace_log(workspace_id, entry)?;
754 (
755 ControlResponse::Ack {
756 message: "workspace log appended".into(),
757 },
758 true,
759 )
760 }
761 ControlCommand::AgentClearLog { workspace_id } => {
762 model.clear_workspace_log(workspace_id)?;
763 (
764 ControlResponse::Ack {
765 message: "workspace log cleared".into(),
766 },
767 true,
768 )
769 }
770 ControlCommand::AgentCreateNotification {
771 target,
772 kind,
773 title,
774 subtitle,
775 external_id,
776 message,
777 state,
778 } => {
779 model.create_agent_notification(
780 target,
781 kind,
782 title,
783 subtitle,
784 external_id,
785 message,
786 state,
787 )?;
788 (
789 ControlResponse::Ack {
790 message: "agent notification created".into(),
791 },
792 true,
793 )
794 }
795 ControlCommand::OpenNotification {
796 window_id,
797 notification_id,
798 } => {
799 model
800 .open_notification(window_id.unwrap_or(model.active_window), notification_id)?;
801 (
802 ControlResponse::Ack {
803 message: "notification opened".into(),
804 },
805 true,
806 )
807 }
808 ControlCommand::ClearNotification { notification_id } => {
809 model.clear_notification(notification_id)?;
810 (
811 ControlResponse::Ack {
812 message: "notification cleared".into(),
813 },
814 true,
815 )
816 }
817 ControlCommand::MarkNotificationDelivery {
818 notification_id,
819 delivery,
820 } => {
821 model.mark_notification_delivery(notification_id, delivery)?;
822 (
823 ControlResponse::Ack {
824 message: "notification delivery updated".into(),
825 },
826 true,
827 )
828 }
829 ControlCommand::AgentClearNotifications { target } => {
830 model.clear_agent_notifications(target)?;
831 (
832 ControlResponse::Ack {
833 message: "agent notifications cleared".into(),
834 },
835 true,
836 )
837 }
838 ControlCommand::DismissSurfaceAlert {
839 workspace_id,
840 pane_id,
841 surface_id,
842 } => {
843 model.dismiss_surface_alert(workspace_id, pane_id, surface_id)?;
844 (
845 ControlResponse::Ack {
846 message: "surface alert dismissed".into(),
847 },
848 true,
849 )
850 }
851 ControlCommand::DismissInterruptedAgentResume {
852 workspace_id,
853 pane_id,
854 surface_id,
855 } => {
856 model.dismiss_interrupted_agent_resume(workspace_id, pane_id, surface_id)?;
857 (
858 ControlResponse::Ack {
859 message: "interrupted agent resume dismissed".into(),
860 },
861 true,
862 )
863 }
864 ControlCommand::AgentTriggerFlash {
865 workspace_id,
866 pane_id,
867 surface_id,
868 } => {
869 model.trigger_surface_flash(workspace_id, pane_id, surface_id)?;
870 (
871 ControlResponse::Ack {
872 message: "surface flash triggered".into(),
873 },
874 true,
875 )
876 }
877 ControlCommand::AgentFocusLatestUnread { window_id } => {
878 model.focus_latest_unread(window_id.unwrap_or(model.active_window))?;
879 (
880 ControlResponse::Ack {
881 message: "focused latest unread activity".into(),
882 },
883 true,
884 )
885 }
886 ControlCommand::Browser { .. } => {
887 return Err(DomainError::InvalidOperation(
888 "browser automation commands require a live GTK host",
889 ));
890 }
891 ControlCommand::Screenshot { .. } => {
892 return Err(DomainError::InvalidOperation(
893 "screenshot commands require a live GTK host",
894 ));
895 }
896 ControlCommand::TerminalDebug { .. } => {
897 return Err(DomainError::InvalidOperation(
898 "terminal debug commands require a live GTK host",
899 ));
900 }
901 ControlCommand::Vcs { .. } => {
902 return Err(DomainError::InvalidOperation(
903 "vcs commands require app runtime support",
904 ));
905 }
906 ControlCommand::QueryStatus { query } => match query {
907 ControlQuery::ActiveWindow | ControlQuery::All => (
908 ControlResponse::Status {
909 session: model.snapshot(),
910 },
911 false,
912 ),
913 ControlQuery::Window { window_id } => (window_snapshot(model, window_id)?, false),
914 ControlQuery::Workspace { workspace_id } => {
915 (workspace_snapshot(model, workspace_id)?, false)
916 }
917 ControlQuery::Identify {
918 workspace_id,
919 pane_id,
920 surface_id,
921 } => (
922 ControlResponse::Identify {
923 result: identify_snapshot(model, workspace_id, pane_id, surface_id)?,
924 },
925 false,
926 ),
927 },
928 };
929
930 if mutated {
931 state.revision = state.revision.saturating_add(1);
932 }
933
934 Ok(response)
935 }
936}
937
938fn window_snapshot(model: &AppModel, window_id: WindowId) -> Result<ControlResponse, DomainError> {
939 let _ = model
940 .windows
941 .get(&window_id)
942 .ok_or(DomainError::MissingWindow(window_id))?;
943 Ok(ControlResponse::Status {
944 session: model.snapshot(),
945 })
946}
947
948fn workspace_snapshot(
949 model: &AppModel,
950 workspace_id: WorkspaceId,
951) -> Result<ControlResponse, DomainError> {
952 let _ = model
953 .workspaces
954 .get(&workspace_id)
955 .ok_or(DomainError::MissingWorkspace(workspace_id))?;
956 Ok(ControlResponse::WorkspaceState {
957 workspace_id,
958 session: model.snapshot(),
959 })
960}
961
962fn identify_snapshot(
963 model: &AppModel,
964 workspace_id: Option<WorkspaceId>,
965 pane_id: Option<PaneId>,
966 surface_id: Option<SurfaceId>,
967) -> Result<IdentifyResult, DomainError> {
968 let focused = focused_identify_context(model)?;
969 let caller = if workspace_id.is_some() || pane_id.is_some() || surface_id.is_some() {
970 Some(resolve_identify_context(
971 model,
972 workspace_id,
973 pane_id,
974 surface_id,
975 )?)
976 } else {
977 None
978 };
979
980 Ok(IdentifyResult { focused, caller })
981}
982
983fn focused_identify_context(model: &AppModel) -> Result<IdentifyContext, DomainError> {
984 let window_id = model.active_window;
985 let workspace = model
986 .active_workspace()
987 .ok_or(DomainError::InvalidOperation("app has no active workspace"))?;
988 let pane = workspace
989 .panes
990 .get(&workspace.active_pane)
991 .ok_or(DomainError::MissingPane(workspace.active_pane))?;
992 let surface = pane
993 .surfaces
994 .get(&pane.active_surface)
995 .ok_or(DomainError::MissingSurface(pane.active_surface))?;
996
997 Ok(identify_context_from_parts(
998 window_id, workspace, pane.id, surface,
999 ))
1000}
1001
1002fn resolve_identify_context(
1003 model: &AppModel,
1004 workspace_id: Option<WorkspaceId>,
1005 pane_id: Option<PaneId>,
1006 surface_id: Option<SurfaceId>,
1007) -> Result<IdentifyContext, DomainError> {
1008 let window_id = model.active_window;
1009
1010 if let Some(surface_id) = surface_id {
1011 for (candidate_workspace_id, workspace) in &model.workspaces {
1012 if workspace_id.is_some_and(|expected| expected != *candidate_workspace_id) {
1013 continue;
1014 }
1015 for (candidate_pane_id, pane) in &workspace.panes {
1016 if pane_id.is_some_and(|expected| expected != *candidate_pane_id) {
1017 continue;
1018 }
1019 if let Some(surface) = pane.surfaces.get(&surface_id) {
1020 return Ok(identify_context_from_parts(
1021 window_id,
1022 workspace,
1023 *candidate_pane_id,
1024 surface,
1025 ));
1026 }
1027 }
1028 }
1029 return Err(DomainError::MissingSurface(surface_id));
1030 }
1031
1032 if let Some(pane_id) = pane_id {
1033 for (candidate_workspace_id, workspace) in &model.workspaces {
1034 if workspace_id.is_some_and(|expected| expected != *candidate_workspace_id) {
1035 continue;
1036 }
1037 if let Some(pane) = workspace.panes.get(&pane_id) {
1038 let surface = pane
1039 .surfaces
1040 .get(&pane.active_surface)
1041 .ok_or(DomainError::MissingSurface(pane.active_surface))?;
1042 return Ok(identify_context_from_parts(
1043 window_id, workspace, pane_id, surface,
1044 ));
1045 }
1046 }
1047 return Err(DomainError::MissingPane(pane_id));
1048 }
1049
1050 if let Some(workspace_id) = workspace_id {
1051 let workspace = model
1052 .workspaces
1053 .get(&workspace_id)
1054 .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1055 let pane = workspace
1056 .panes
1057 .get(&workspace.active_pane)
1058 .ok_or(DomainError::MissingPane(workspace.active_pane))?;
1059 let surface = pane
1060 .surfaces
1061 .get(&pane.active_surface)
1062 .ok_or(DomainError::MissingSurface(pane.active_surface))?;
1063 return Ok(identify_context_from_parts(
1064 window_id, workspace, pane.id, surface,
1065 ));
1066 }
1067
1068 focused_identify_context(model)
1069}
1070
1071fn identify_context_from_parts(
1072 window_id: WindowId,
1073 workspace: &taskers_domain::Workspace,
1074 pane_id: PaneId,
1075 surface: &SurfaceRecord,
1076) -> IdentifyContext {
1077 IdentifyContext {
1078 window_id,
1079 workspace_id: workspace.id,
1080 workspace_label: workspace.label.clone(),
1081 workspace_window_id: workspace.window_for_pane(pane_id),
1082 pane_id,
1083 surface_id: surface.id,
1084 surface_kind: surface.kind.clone(),
1085 title: normalized_value(surface.metadata.title.as_deref()),
1086 cwd: normalized_value(surface.metadata.cwd.as_deref()),
1087 url: normalized_value(surface.metadata.url.as_deref()),
1088 loading: matches!(surface.kind, PaneKind::Browser).then_some(false),
1089 }
1090}
1091
1092fn normalized_value(value: Option<&str>) -> Option<String> {
1093 value
1094 .map(str::trim)
1095 .filter(|value| !value.is_empty())
1096 .map(str::to_owned)
1097}
1098
1099#[cfg(test)]
1100mod tests {
1101 use taskers_domain::{AppModel, BrowserProfileMode, PaneKind, SignalEvent, SignalKind};
1102
1103 use crate::{
1104 ControlCommand, ControlQuery, ControlResponse, ScreenshotCommand, ScreenshotTarget,
1105 };
1106
1107 use super::InMemoryController;
1108
1109 #[test]
1110 fn revision_increments_for_mutations_but_not_queries() {
1111 let controller = InMemoryController::new(AppModel::new("Main"));
1112 assert_eq!(controller.revision(), 0);
1113
1114 controller
1115 .handle(ControlCommand::QueryStatus {
1116 query: ControlQuery::All,
1117 })
1118 .expect("query status");
1119 assert_eq!(controller.revision(), 0);
1120
1121 controller
1122 .handle(ControlCommand::CreateWorkspace {
1123 label: "Docs".into(),
1124 })
1125 .expect("create workspace");
1126 assert_eq!(controller.revision(), 1);
1127 assert_eq!(controller.snapshot().revision, 1);
1128 }
1129
1130 #[test]
1131 fn revision_increments_for_signal_mutations() {
1132 let controller = InMemoryController::new(AppModel::new("Main"));
1133 let snapshot = controller.snapshot();
1134 let workspace = snapshot.model.active_workspace().expect("workspace");
1135
1136 controller
1137 .handle(ControlCommand::EmitSignal {
1138 workspace_id: workspace.id,
1139 pane_id: workspace.active_pane,
1140 surface_id: None,
1141 event: SignalEvent::new("pty", SignalKind::Progress, Some("Running".into())),
1142 })
1143 .expect("emit signal");
1144
1145 assert_eq!(controller.revision(), 1);
1146 }
1147
1148 #[test]
1149 fn surface_signals_follow_a_moved_surface_even_with_stale_pane_context() {
1150 let controller = InMemoryController::new(AppModel::new("Main"));
1151 let snapshot = controller.snapshot();
1152 let source_workspace = snapshot.model.active_workspace().expect("workspace");
1153 let source_workspace_id = source_workspace.id;
1154 let source_pane_id = source_workspace.active_pane;
1155
1156 controller
1157 .handle(ControlCommand::CreateSurface {
1158 workspace_id: source_workspace_id,
1159 pane_id: source_pane_id,
1160 kind: PaneKind::Browser,
1161 browser_profile_mode: Some(BrowserProfileMode::PersistentDefault),
1162 })
1163 .expect("create moved surface");
1164 let moved_surface_id = controller
1165 .snapshot()
1166 .model
1167 .workspaces
1168 .get(&source_workspace_id)
1169 .and_then(|workspace| workspace.panes.get(&source_pane_id))
1170 .map(|pane| pane.active_surface)
1171 .expect("moved surface");
1172
1173 controller
1174 .handle(ControlCommand::CreateWorkspace {
1175 label: "Docs".into(),
1176 })
1177 .expect("create target workspace");
1178 let target_workspace_id = controller
1179 .snapshot()
1180 .model
1181 .active_workspace_id()
1182 .expect("target workspace");
1183
1184 controller
1185 .handle(ControlCommand::MoveSurfaceToWorkspace {
1186 source_workspace_id,
1187 source_pane_id,
1188 surface_id: moved_surface_id,
1189 target_workspace_id,
1190 })
1191 .expect("move surface");
1192
1193 controller
1194 .handle(ControlCommand::EmitSignal {
1195 workspace_id: source_workspace_id,
1196 pane_id: source_pane_id,
1197 surface_id: Some(moved_surface_id),
1198 event: SignalEvent::new("pty", SignalKind::Progress, Some("Running".into())),
1199 })
1200 .expect("emit moved surface signal");
1201
1202 let snapshot = controller.snapshot();
1203 let target_surface = snapshot
1204 .model
1205 .workspaces
1206 .values()
1207 .flat_map(|workspace| {
1208 workspace.panes.values().flat_map(move |pane| {
1209 pane.surfaces
1210 .values()
1211 .map(move |surface| (workspace, pane, surface))
1212 })
1213 })
1214 .find(|(_, _, surface)| surface.id == moved_surface_id)
1215 .expect("target surface");
1216
1217 assert_eq!(target_surface.0.id, target_workspace_id);
1218 assert_eq!(
1219 target_surface.2.attention,
1220 taskers_domain::AttentionState::Busy
1221 );
1222 assert_eq!(
1223 snapshot
1224 .model
1225 .workspaces
1226 .get(&source_workspace_id)
1227 .and_then(|workspace| workspace.panes.get(&source_pane_id))
1228 .and_then(|pane| pane.active_surface())
1229 .map(|surface| surface.attention),
1230 Some(taskers_domain::AttentionState::Normal)
1231 );
1232 }
1233
1234 #[test]
1235 fn surface_agent_start_follows_a_moved_surface_even_with_stale_pane_context() {
1236 let controller = InMemoryController::new(AppModel::new("Main"));
1237 let snapshot = controller.snapshot();
1238 let source_workspace = snapshot.model.active_workspace().expect("workspace");
1239 let source_workspace_id = source_workspace.id;
1240 let source_pane_id = source_workspace.active_pane;
1241
1242 controller
1243 .handle(ControlCommand::CreateSurface {
1244 workspace_id: source_workspace_id,
1245 pane_id: source_pane_id,
1246 kind: PaneKind::Browser,
1247 browser_profile_mode: Some(BrowserProfileMode::PersistentDefault),
1248 })
1249 .expect("create moved surface");
1250 let moved_surface_id = controller
1251 .snapshot()
1252 .model
1253 .workspaces
1254 .get(&source_workspace_id)
1255 .and_then(|workspace| workspace.panes.get(&source_pane_id))
1256 .map(|pane| pane.active_surface)
1257 .expect("moved surface");
1258
1259 controller
1260 .handle(ControlCommand::CreateWorkspace {
1261 label: "Docs".into(),
1262 })
1263 .expect("create target workspace");
1264 let target_workspace_id = controller
1265 .snapshot()
1266 .model
1267 .active_workspace_id()
1268 .expect("target workspace");
1269
1270 controller
1271 .handle(ControlCommand::MoveSurfaceToWorkspace {
1272 source_workspace_id,
1273 source_pane_id,
1274 surface_id: moved_surface_id,
1275 target_workspace_id,
1276 })
1277 .expect("move surface");
1278
1279 controller
1280 .handle(ControlCommand::StartSurfaceAgentSession {
1281 workspace_id: source_workspace_id,
1282 pane_id: source_pane_id,
1283 surface_id: moved_surface_id,
1284 agent_kind: "codex".into(),
1285 })
1286 .expect("start moved surface agent session");
1287
1288 let snapshot = controller.snapshot();
1289 let target_surface = snapshot
1290 .model
1291 .workspaces
1292 .values()
1293 .flat_map(|workspace| {
1294 workspace.panes.values().flat_map(move |pane| {
1295 pane.surfaces
1296 .values()
1297 .map(move |surface| (workspace, pane, surface))
1298 })
1299 })
1300 .find(|(_, _, surface)| surface.id == moved_surface_id)
1301 .expect("target surface");
1302
1303 assert_eq!(target_surface.0.id, target_workspace_id);
1304 assert!(target_surface.2.agent_process.is_some());
1305 assert!(target_surface.2.agent_session.is_none());
1306 assert_eq!(
1307 target_surface.2.metadata.agent_kind.as_deref(),
1308 Some("codex")
1309 );
1310 assert!(target_surface.2.metadata.agent_active);
1311 assert!(
1312 snapshot
1313 .model
1314 .workspaces
1315 .get(&source_workspace_id)
1316 .and_then(|workspace| workspace.panes.get(&source_pane_id))
1317 .and_then(|pane| pane.active_surface())
1318 .and_then(|surface| surface.agent_process.as_ref())
1319 .is_none()
1320 );
1321 }
1322
1323 #[test]
1324 fn surface_agent_stop_follows_a_moved_surface_even_with_stale_pane_context() {
1325 let controller = InMemoryController::new(AppModel::new("Main"));
1326 let snapshot = controller.snapshot();
1327 let source_workspace = snapshot.model.active_workspace().expect("workspace");
1328 let source_workspace_id = source_workspace.id;
1329 let source_pane_id = source_workspace.active_pane;
1330
1331 let moved_surface_id = source_workspace
1332 .panes
1333 .get(&source_pane_id)
1334 .and_then(|pane| pane.active_surface())
1335 .map(|surface| surface.id)
1336 .expect("surface");
1337
1338 controller
1339 .handle(ControlCommand::StartSurfaceAgentSession {
1340 workspace_id: source_workspace_id,
1341 pane_id: source_pane_id,
1342 surface_id: moved_surface_id,
1343 agent_kind: "codex".into(),
1344 })
1345 .expect("start surface agent session");
1346
1347 controller
1348 .handle(ControlCommand::CreateWorkspace {
1349 label: "Docs".into(),
1350 })
1351 .expect("create target workspace");
1352 let target_workspace_id = controller
1353 .snapshot()
1354 .model
1355 .active_workspace_id()
1356 .expect("target workspace");
1357
1358 controller
1359 .handle(ControlCommand::MoveSurfaceToWorkspace {
1360 source_workspace_id,
1361 source_pane_id,
1362 surface_id: moved_surface_id,
1363 target_workspace_id,
1364 })
1365 .expect("move surface");
1366
1367 controller
1368 .handle(ControlCommand::StopSurfaceAgentSession {
1369 workspace_id: source_workspace_id,
1370 pane_id: source_pane_id,
1371 surface_id: moved_surface_id,
1372 exit_status: 1,
1373 })
1374 .expect("stop moved surface agent session");
1375
1376 let snapshot = controller.snapshot();
1377 let target_surface = snapshot
1378 .model
1379 .workspaces
1380 .values()
1381 .flat_map(|workspace| {
1382 workspace.panes.values().flat_map(move |pane| {
1383 pane.surfaces
1384 .values()
1385 .map(move |surface| (workspace, pane, surface))
1386 })
1387 })
1388 .find(|(_, _, surface)| surface.id == moved_surface_id)
1389 .expect("target surface");
1390
1391 assert_eq!(target_surface.0.id, target_workspace_id);
1392 assert!(target_surface.2.agent_process.is_none());
1393 assert!(target_surface.2.agent_session.is_none());
1394 assert_eq!(
1395 target_surface.2.attention,
1396 taskers_domain::AttentionState::Error
1397 );
1398 assert!(
1399 snapshot
1400 .model
1401 .workspaces
1402 .get(&source_workspace_id)
1403 .and_then(|workspace| workspace.panes.get(&source_pane_id))
1404 .and_then(|pane| pane.active_surface())
1405 .and_then(|surface| surface.agent_process.as_ref())
1406 .is_none()
1407 );
1408 }
1409
1410 #[test]
1411 fn identify_returns_focused_context_and_optional_caller() {
1412 let controller = InMemoryController::new(AppModel::new("Main"));
1413 let snapshot = controller.snapshot();
1414 let workspace = snapshot.model.active_workspace().expect("workspace");
1415 let pane = workspace
1416 .panes
1417 .get(&workspace.active_pane)
1418 .expect("active pane");
1419 let surface = pane.active_surface().expect("active surface");
1420
1421 let response = controller
1422 .handle(ControlCommand::QueryStatus {
1423 query: ControlQuery::Identify {
1424 workspace_id: None,
1425 pane_id: None,
1426 surface_id: None,
1427 },
1428 })
1429 .expect("identify focused");
1430 let ControlResponse::Identify { result } = response else {
1431 panic!("unexpected identify response");
1432 };
1433 assert_eq!(result.focused.workspace_id, workspace.id);
1434 assert_eq!(result.focused.pane_id, workspace.active_pane);
1435 assert_eq!(result.focused.surface_id, surface.id);
1436 assert_eq!(result.focused.surface_kind, PaneKind::Terminal);
1437 assert!(result.caller.is_none());
1438
1439 let response = controller
1440 .handle(ControlCommand::QueryStatus {
1441 query: ControlQuery::Identify {
1442 workspace_id: Some(workspace.id),
1443 pane_id: Some(workspace.active_pane),
1444 surface_id: Some(surface.id),
1445 },
1446 })
1447 .expect("identify caller");
1448 let ControlResponse::Identify { result } = response else {
1449 panic!("unexpected identify response");
1450 };
1451 let caller = result.caller.expect("caller context");
1452 assert_eq!(caller.workspace_id, workspace.id);
1453 assert_eq!(caller.pane_id, workspace.active_pane);
1454 assert_eq!(caller.surface_id, surface.id);
1455 }
1456
1457 #[test]
1458 fn create_surface_applies_requested_browser_profile_mode() {
1459 let controller = InMemoryController::new(AppModel::new("Main"));
1460 let snapshot = controller.snapshot();
1461 let workspace = snapshot.model.active_workspace().expect("workspace");
1462
1463 controller
1464 .handle(ControlCommand::CreateSurface {
1465 workspace_id: workspace.id,
1466 pane_id: workspace.active_pane,
1467 kind: PaneKind::Browser,
1468 browser_profile_mode: Some(BrowserProfileMode::Ephemeral),
1469 })
1470 .expect("create browser surface");
1471
1472 let snapshot = controller.snapshot();
1473 let browser_surface = snapshot
1474 .model
1475 .workspaces
1476 .get(&workspace.id)
1477 .and_then(|workspace| workspace.panes.get(&workspace.active_pane))
1478 .and_then(|pane| pane.active_surface())
1479 .expect("browser surface");
1480
1481 assert_eq!(
1482 browser_surface.metadata.browser_profile_mode,
1483 BrowserProfileMode::Ephemeral
1484 );
1485 }
1486
1487 #[test]
1488 fn screenshot_commands_require_live_host() {
1489 let controller = InMemoryController::new(AppModel::new("Main"));
1490 let snapshot = controller.snapshot();
1491 let workspace = snapshot.model.active_workspace().expect("workspace");
1492
1493 let error = controller
1494 .handle(ControlCommand::Screenshot {
1495 screenshot_command: ScreenshotCommand::Capture {
1496 target: ScreenshotTarget::WorkspaceCanvas {
1497 workspace_id: workspace.id,
1498 },
1499 path: None,
1500 },
1501 })
1502 .expect_err("screenshot should require live host");
1503
1504 assert!(
1505 error.to_string().contains("live GTK host"),
1506 "unexpected error: {error}"
1507 );
1508 }
1509}