21 KiB
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
UserDefaultsas 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, orUserDefaults. - 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
TerminalViewcannot 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:
- Global app state Global settings, hotkeys, launch behavior, workspace metadata, screen assignments.
- Workspace state Tabs, active tab, terminal sessions, workspace title, workspace-local behavior.
- 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
AppSettingsStoreLoads and saves global settings. Publishes typed changes.WorkspaceRegistryOwns all workspaces and workspace metadata.ScreenRegistryTracks connected screens and their screen-local state.NotchOrchestratorCoordinates notch lifecycle per screen.WindowCoordinatorOwns AppKit windows/panels and binds them to screen models.HotkeyServiceRegisters global and local hotkeys and emits typed intents.SettingsCoordinatorPresents settings UI.PopoutCoordinatorPresents 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
typealias WorkspaceID = UUID
typealias ScreenID = String
typealias TabID = UUID
typealias SessionID = UUID
App Settings
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
struct WorkspaceSummary: Equatable, Codable, Identifiable {
var id: WorkspaceID
var name: String
var createdAt: Date
}
Workspace Assignment
struct ScreenWorkspaceAssignment: Equatable, Codable {
var screenID: ScreenID
var workspaceID: WorkspaceID
}
Screen UI State
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
struct WorkspaceState: Identifiable {
var id: WorkspaceID
var name: String
var tabs: [TerminalTabState]
var activeTabID: TabID?
}
Tab State
struct TerminalTabState: Identifiable {
var id: TabID
var sessionID: SessionID
var title: String
}
Notch Lifecycle State
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:
@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:
@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
ScreenContextfor each screen - maintain screen-to-workspace assignment
- rebuild state on screen changes
Suggested API:
@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
NotchWindowper screen - bind
ScreenContextto 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:
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:
TerminalSessionProcess + session metadata + delegate event translationTerminalViewHostOwns aTerminalViewfor one active screen/window
Minimal first-step compromise:
- Keep
TerminalSessionowning oneTerminalView - 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:
AppSettingsStorePersistence boundaryAppSettingsControllerIn-memory observable runtime settings
Suggested pattern:
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:
appSettingsworkspaceSummariesscreenAssignmentsterminalSizePresets
Prefer a single encoded settings object per concern over many independent keys.
UI Structure
Root Views
NotchRootViewClosedNotchViewOpenWorkspaceViewTabStripViewWorkspacePickerViewSettingsRootView
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
Won screenBclosesWon screenA
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
@AppStorageusage into typed settings reads/writes - make
HotkeyServiceobserve 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
TerminalManagerlogic intoWorkspaceController - ensure one default workspace exists
Exit criteria:
- tabs exist inside a workspace object, not globally
Phase 3: Screen Core
- add
ScreenContext,ScreenRegistry - migrate current
NotchViewModelresponsibilities intoScreenContext - 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:
ContentViewbecomes mostly rendering + view intents
Phase 5: Window Coordination
- extract AppKit window creation and frame management into
WindowCoordinator - keep
ScreenManageras 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
Downterm/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:
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
NSWindowinternals 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 UserDefaultsas 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
AppSettingsmodel - 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:
- add tests target
- add
AppSettings - add
WorkspaceRegistrywith a single default workspace - migrate current
TerminalManagerinto a firstWorkspaceController - 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
UserDefaultsnotifications - 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