# 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

