Skip to main content

taskers_control/
controller.rs

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}