# Workspace Architecture Spec ## Purpose This document defines the target architecture for refactoring CommandNotch into a maintainable, testable macOS app with: - explicit runtime state ownership - virtual workspaces shared across screens - typed settings instead of `UserDefaults` as the event bus - reduced singleton coupling - a realistic testing strategy for logic, integration, and UI This is the implementation reference for the refactor. ## Goals - Preserve the current product behavior where possible. - Support multiple virtual workspaces. - Allow multiple screens to point at the same workspace. - Allow screens to switch workspaces independently. - Keep screen-specific UI state local to each screen. - Make business logic unit testable without real `NSWindow`, `NSScreen`, or `UserDefaults`. - Keep AppKit/SwiftUI at the edges. ## Non-Goals - Multi-window rendering of the same live terminal instance at the same time. - Full state restoration of shell process contents across launches. - Perfect backward compatibility for internal architecture. - Solving all visual polish issues during the refactor. ## Constraints - A `TerminalView` cannot safely belong to more than one window/view hierarchy at once. - A `LocalProcess`-backed session is inherently stateful and UI-coupled through SwiftTerm. - The app is macOS-only and already depends on AppKit + SwiftUI + SwiftTerm. ## Target Mental Model There are three layers of state: 1. Global app state Global settings, hotkeys, launch behavior, workspace metadata, screen assignments. 2. Workspace state Tabs, active tab, terminal sessions, workspace title, workspace-local behavior. 3. Screen state Which workspace a screen is viewing, notch geometry, open/closed state, hover state, focus state. The current app blurs these boundaries. The refactor makes them explicit. ## High-Level Architecture ### Composition Root `AppDelegate` becomes the composition root. It wires concrete implementations together and passes them into coordinators/controllers. It should be the only place that knows about most concrete types. ### Core Modules - `AppSettingsStore` Loads and saves global settings. Publishes typed changes. - `WorkspaceRegistry` Owns all workspaces and workspace metadata. - `ScreenRegistry` Tracks connected screens and their screen-local state. - `NotchOrchestrator` Coordinates notch lifecycle per screen. - `WindowCoordinator` Owns AppKit windows/panels and binds them to screen models. - `HotkeyService` Registers global and local hotkeys and emits typed intents. - `SettingsCoordinator` Presents settings UI. - `PopoutCoordinator` Presents detached terminal windows. ### UI Boundaries - SwiftUI views render state and emit intents only. - AppKit controllers own `NSWindow`, `NSPanel`, `NSHostingView`. - SwiftTerm integration is behind a terminal session abstraction. ## Ownership Model ### AppSettings Owns: - app-wide appearance settings - hotkey bindings - launch-at-login - menu bar visibility - default workspace behavior - workspace assignment persistence Does not own: - transient window geometry - active terminal sessions - per-screen hover/open state ### Workspace Owns: - `workspaceID` - display name - ordered tabs - active tab selection - detached tab metadata if retained - workspace-local terminal state Does not own: - screen assignment - notch geometry - whether a screen is open or closed ### ScreenContext Owns: - `screenID` - assigned `workspaceID` - notch presentation state - current notch frame/size - hover state - local focus state - local transition state Does not own: - tabs - terminal session collection - app-wide settings ## Required Domain Types ### Identifiers ```swift typealias WorkspaceID = UUID typealias ScreenID = String typealias TabID = UUID typealias SessionID = UUID ``` ### App Settings ```swift struct AppSettings: Equatable, Codable { var showMenuBarIcon: Bool var showOnAllDisplays: Bool var launchAtLogin: Bool var appearance: AppearanceSettings var animation: AnimationSettings var terminal: TerminalSettings var hotkeys: HotkeySettings } ``` ### Workspace Summary ```swift struct WorkspaceSummary: Equatable, Codable, Identifiable { var id: WorkspaceID var name: String var createdAt: Date } ``` ### Workspace Assignment ```swift struct ScreenWorkspaceAssignment: Equatable, Codable { var screenID: ScreenID var workspaceID: WorkspaceID } ``` ### Screen UI State ```swift struct ScreenUIState: Equatable { var screenID: ScreenID var workspaceID: WorkspaceID var notchState: NotchPresentationState var notchSize: CGSize var closedNotchSize: CGSize var isHovering: Bool var isFocused: Bool var transitionState: NotchTransitionState } ``` ### Workspace State ```swift struct WorkspaceState: Identifiable { var id: WorkspaceID var name: String var tabs: [TerminalTabState] var activeTabID: TabID? } ``` ### Tab State ```swift struct TerminalTabState: Identifiable { var id: TabID var sessionID: SessionID var title: String } ``` ### Notch Lifecycle State ```swift enum NotchPresentationState: Equatable { case closed case open } enum NotchTransitionState: Equatable { case idle case opening case closing case resizingUser case resizingPreset } ``` ## Target Runtime Objects ### `AppController` Top-level coordinator created by `AppDelegate`. Responsibilities: - boot app services - respond to lifecycle events - connect hotkey intents to workspace/screen actions - own references to main long-lived services ### `WorkspaceRegistry` Responsibilities: - create workspace - delete workspace - rename workspace - fetch workspace by id - publish workspace list changes - ensure at least one workspace exists Suggested API: ```swift @MainActor protocol WorkspaceRegistryType: AnyObject { var workspaceSummariesPublisher: AnyPublisher<[WorkspaceSummary], Never> { get } func allWorkspaceSummaries() -> [WorkspaceSummary] func workspaceController(for id: WorkspaceID) -> WorkspaceControllerType? func createWorkspace(named: String?) -> WorkspaceID func deleteWorkspace(id: WorkspaceID) func renameWorkspace(id: WorkspaceID, name: String) func ensureWorkspaceExists() -> WorkspaceID } ``` ### `WorkspaceController` Replaces most of `TerminalManager`. Responsibilities: - own tabs for a single workspace - create and close tabs - detach tabs - switch active tab - update session appearance when settings change - publish workspace state Suggested API: ```swift @MainActor protocol WorkspaceControllerType: ObservableObject { var state: WorkspaceState { get } func newTab() func closeTab(id: TabID) func closeActiveTab() func switchToTab(id: TabID) func switchToTab(index: Int) func nextTab() func previousTab() func detachActiveTab() -> TerminalSessionType? func updateTheme(_ theme: TerminalTheme) func updateFontSize(_ size: CGFloat) } ``` ### `ScreenRegistry` Responsibilities: - discover connected screens - maintain `ScreenContext` for each screen - maintain screen-to-workspace assignment - rebuild state on screen changes Suggested API: ```swift @MainActor protocol ScreenRegistryType: AnyObject { var screenContextsPublisher: AnyPublisher<[ScreenContext], Never> { get } func allScreens() -> [ScreenContext] func screenContext(for id: ScreenID) -> ScreenContext? func assignWorkspace(_ workspaceID: WorkspaceID, to screenID: ScreenID) func activeScreenID() -> ScreenID? func refreshConnectedScreens() } ``` ### `ScreenContext` Observable object for one physical display. Responsibilities: - store local UI state - expose a current `workspaceID` - emit user intents for local notch behavior This should replace today’s overloaded `NotchViewModel`. ### `NotchOrchestrator` Responsibilities: - open/close notch for a screen - coordinate focus, hover, suppression, and transitions - enforce transition rules - drive frame updates indirectly through window coordination The orchestrator should own the state machine, not `ContentView`. ### `WindowCoordinator` Responsibilities: - create/destroy one `NotchWindow` per screen - bind `ScreenContext` to window content - update window frames - respond to `resignKey` - focus the correct terminal view This keeps AppKit-specific code out of registry/controller classes. ### `HotkeyService` Responsibilities: - register global hotkey bindings - observe only hotkey-related settings changes - emit typed commands Suggested intent enum: ```swift enum AppCommand { case toggleNotch(screenID: ScreenID?) case newTab(workspaceID: WorkspaceID) case closeTab(workspaceID: WorkspaceID) case nextTab(workspaceID: WorkspaceID) case previousTab(workspaceID: WorkspaceID) case switchToTab(workspaceID: WorkspaceID, index: Int) case detachTab(workspaceID: WorkspaceID) case applySizePreset(screenID: ScreenID, presetID: UUID) case switchWorkspace(screenID: ScreenID, workspaceID: WorkspaceID) case createWorkspace(screenID: ScreenID) } ``` ## Terminal Session Design ### Current Problem `TerminalSession` mixes: - process lifecycle - SwiftTerm view ownership - delegate callbacks - some UI-facing published state This makes reuse difficult. ### Target Split Introduce: - `TerminalSession` Process + session metadata + delegate event translation - `TerminalViewHost` Owns a `TerminalView` for one active screen/window Minimal first-step compromise: - Keep `TerminalSession` owning one `TerminalView` - Enforce that only one screen at a time can actively render a given workspace - Make that rule explicit in the architecture Recommended v1 rule: - many screens may point at the same workspace - only one screen may have that workspace open and focused at a time This avoids trying to mirror one live terminal view into multiple windows. ## Settings Design ### Current Problem `@AppStorage` is scattered across many views and managers, and `UserDefaults` changes trigger broad runtime work. ### Target Design Introduce: - `AppSettingsStore` Persistence boundary - `AppSettingsController` In-memory observable runtime settings Suggested pattern: ```swift protocol AppSettingsStoreType { func load() -> AppSettings func save(_ settings: AppSettings) } @MainActor final class AppSettingsController: ObservableObject { @Published private(set) var settings: AppSettings func update(_ update: (inout AppSettings) -> Void) } ``` Views bind to `AppSettingsController`. Services subscribe to precise sub-slices of settings. ## Persistence Design Persist only durable data: - app settings - workspace summaries - screen-to-workspace assignments - optional workspace-local preferences - size presets Do not persist: - live `LocalProcess` - active shell buffer contents - transient hover/focus/transition state Suggested storage keys: - `appSettings` - `workspaceSummaries` - `screenAssignments` - `terminalSizePresets` Prefer a single encoded settings object per concern over many independent keys. ## UI Structure ### Root Views - `NotchRootView` - `ClosedNotchView` - `OpenWorkspaceView` - `TabStripView` - `WorkspacePickerView` - `SettingsRootView` ### View Rules - Views may read state from observable objects. - Views may emit intents via closures or controller methods. - Views do not call global singletons. - Views do not own delayed business logic unless it is purely visual. ## Workspace UX Rules - Every screen always has an assigned workspace. - A new screen defaults to: - the app default workspace strategy, or - the first existing workspace - Users can: - switch a screen to another workspace - create a new workspace from a screen - rename a workspace - Deleting a workspace: - reassigns affected screens to a fallback workspace - is disallowed if it is the last workspace ## Screen UX Rules - A screen can be open/closed independently of other screens. - Two screens may point at the same workspace. - If a workspace is already open on another screen: - opening it on this screen should either transfer focus, or - close it on the other screen first Recommended v1 behavior: - one active open screen per workspace - opening workspace `W` on screen `B` closes `W` on screen `A` That is simple, deterministic, and compatible with single `TerminalView` ownership. ## Migration Plan ### Phase 0: Hygiene - remove dead settings and unused properties - fix version drift - fix stale comments - add test targets - isolate root build artifacts from source tree if desired ### Phase 1: Settings Layer - create `AppSettings`, `AppSettingsStore`, `AppSettingsController` - move scattered `@AppStorage` usage into typed settings reads/writes - make `HotkeyService` observe only hotkey settings - make window sizing observe only relevant settings Exit criteria: - no runtime logic depends on `UserDefaults.didChangeNotification` ### Phase 2: Workspace Core - add `WorkspaceSummary`, `WorkspaceRegistry`, `WorkspaceController` - migrate current `TerminalManager` logic into `WorkspaceController` - ensure one default workspace exists Exit criteria: - tabs exist inside a workspace object, not globally ### Phase 3: Screen Core - add `ScreenContext`, `ScreenRegistry` - migrate current `NotchViewModel` responsibilities into `ScreenContext` - persist screen-to-workspace assignments Exit criteria: - screen-local state is independent from workspace state ### Phase 4: Notch Lifecycle Orchestrator - move hover/open/close/transition logic out of `ContentView` - add explicit transition states - centralize focus/open/close sequencing Exit criteria: - `ContentView` becomes mostly rendering + view intents ### Phase 5: Window Coordination - extract AppKit window creation and frame management into `WindowCoordinator` - keep `ScreenManager` as thin glue or replace it entirely Exit criteria: - no window-specific behavior in workspace logic ### Phase 6: Workspace Switching UX - add workspace picker UI - support create/switch/rename/delete workspaces - enforce one-open-screen-per-workspace behavior Exit criteria: - a user can assign the same workspace to multiple screens ### Phase 7: Popout and Session Cleanup - formalize detached tab ownership - remove session observer leaks - remove app-global current-directory mutation Exit criteria: - session lifetime is explicit and testable ### Phase 8: Test Expansion - add unit coverage for all core models/controllers - add integration tests for orchestrators - add XCUITests for key user flows ## Proposed File Layout ```text CommandNotch/CommandNotch/ App/ CommandNotchApp.swift AppDelegate.swift AppController.swift Core/ Settings/ AppSettings.swift AppSettingsController.swift AppSettingsStore.swift UserDefaultsAppSettingsStore.swift Workspaces/ WorkspaceSummary.swift WorkspaceState.swift WorkspaceRegistry.swift WorkspaceController.swift WorkspaceStore.swift Screens/ ScreenContext.swift ScreenRegistry.swift ScreenAssignmentStore.swift Notch/ NotchOrchestrator.swift NotchStateMachine.swift NotchFrameCalculator.swift Terminal/ TerminalSession.swift TerminalSessionFactory.swift TerminalTheme.swift TerminalTabState.swift Hotkeys/ HotkeyService.swift HotkeyBinding.swift AppCommand.swift UI/ Notch/ NotchRootView.swift ClosedNotchView.swift OpenWorkspaceView.swift TabStripView.swift WorkspacePickerView.swift Settings/ SettingsRootView.swift GeneralSettingsView.swift AppearanceSettingsView.swift AnimationSettingsView.swift TerminalSettingsView.swift HotkeySettingsView.swift WorkspaceSettingsView.swift Components/ SwiftTermView.swift HotkeyRecorderView.swift NotchShape.swift AppKit/ WindowCoordinator.swift NotchWindow.swift SettingsCoordinator.swift PopoutCoordinator.swift ScreenProvider.swift Persistence/ CodableStore.swift Extensions/ NSScreen+Extensions.swift ``` ## Protocol Boundaries Required For Tests Create protocols around: - settings store - workspace store - screen provider - window factory - hotkey registrar - launch-at-login service - terminal session factory - clock/scheduler for delayed hover/open/close logic Examples: ```swift protocol ScreenProviderType { var screens: [ScreenDescriptor] { get } } protocol WindowCoordinatorType { func showWindow(for screenID: ScreenID) func hideWindow(for screenID: ScreenID) func updateFrame(for screenID: ScreenID, frame: CGRect) func focusTerminal(for screenID: ScreenID) } protocol SchedulerType { func schedule(after interval: TimeInterval, _ action: @escaping () -> Void) -> Cancellable } ``` ## Testing Strategy ## 1. Unit Tests Add a `CommandNotchTests` target. Focus on pure logic: - `WorkspaceRegistryTests` - creates default workspace - cannot delete last workspace - rename/delete/create behavior - `WorkspaceControllerTests` - new tab - close active tab - detach active tab - active tab index/id updates correctly - `ScreenRegistryTests` - new screen gets valid assignment - assignment changes persist - missing workspace reassigns to fallback - `NotchStateMachineTests` - closed -> opening -> open - close suppression while hovering - resizing transitions - `NotchFrameCalculatorTests` - clamping - per-screen max bounds - closed-notch size behavior - `HotkeyBindingTests` - event matching - preset digit mapping - `AppSettingsControllerTests` - only targeted changes publish ## 2. Integration Tests Use fakes for AppKit boundaries. - `NotchOrchestratorIntegrationTests` - opening a workspace on one screen closes it on another screen if shared - focus transfer rules - preset resize behavior - `AppControllerIntegrationTests` - hotkey command routes to active screen/workspace - settings changes update workspaces without broad side effects ## 3. UI Tests Add a `CommandNotchUITests` target with XCUITest. Recommended initial flows: - launch app and open settings - create workspace from settings or notch UI - assign workspace to another screen context if testable - create tab - switch tabs - apply size preset - detach active tab UI tests should validate behavior and existence, not exact pixel values. ## 4. Optional Snapshot Tests Only add after state is injectable. Snapshot candidates: - closed notch - open notch with one tab - workspace picker - settings panes Do not start here. ## Testing SwiftUI/AppKit UI ### What to test directly - view model/controller behavior - user intents emitted by the view - XCUITest end-to-end flows ### What not to over-invest in early - exact animation curves - fragile layout pixel assertions - direct testing of `NSWindow` internals unless wrapped behind protocols ### Practical answer For this app, “testing UI” mostly means testing the logic that drives the UI, plus a thin XCUITest layer that proves the main flows work. ## Current Issues Mapped To Fixes - Global singleton tab state Fix with `WorkspaceRegistry` + `WorkspaceController` - `UserDefaults` as runtime bus Fix with typed settings controller and targeted subscriptions - Dead gesture settings Remove or implement later behind a dedicated feature - Observer leakage in tab creation Store cancellables per session or derive titles from workspace state - App-global cwd mutation Remove from session startup; set process cwd explicitly if supported - Monolithic settings file Split into feature files bound to typed settings - Timing-based UI behavior spread across layers Centralize in `NotchOrchestrator` - No tests Add unit + integration + XCUITest targets ## Implementation Checklist - [ ] Add `CommandNotchTests` - [ ] Add `CommandNotchUITests` - [ ] Create `AppSettings` model - [ ] Create `AppSettingsStore` - [ ] Replace broad defaults observation - [ ] Create `WorkspaceRegistry` - [ ] Create `WorkspaceController` - [ ] Move tab logic out of `TerminalManager` - [ ] Create `ScreenContext` - [ ] Create `ScreenRegistry` - [ ] Persist screen assignments - [ ] Create `NotchOrchestrator` - [ ] Create `NotchFrameCalculator` - [ ] Extract `WindowCoordinator` - [ ] Add workspace picker UI - [ ] Add create/switch/rename/delete workspace flows - [ ] Enforce one-open-screen-per-workspace - [ ] Clean up detached session ownership - [ ] Split settings views into separate files - [ ] Remove dead settings and unused code - [ ] Add unit tests for registry/controller/state machine/frame calculator - [ ] Add initial XCUITest flows ## First Concrete Refactor Slice If implementing incrementally, the best first slice is: 1. add tests target 2. add `AppSettings` 3. add `WorkspaceRegistry` with a single default workspace 4. migrate current `TerminalManager` into a first `WorkspaceController` 5. change the app so all current behavior still uses one workspace That gives immediate architectural improvement without changing UX yet. ## Definition Of Done The refactor is successful when: - there is no critical runtime logic driven by broad `UserDefaults` notifications - tabs/sessions live under workspaces, not globally - screens are explicitly assigned to workspaces - the same workspace can be selected on multiple screens - only one open/focused screen owns a workspace’s live terminal at a time - window behavior is separated from domain logic - unit tests cover the core state machines and registries - XCUITests cover main user flows