From 5c61010ae8d936fa88cd08764914339c903dde2b Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Fri, 28 Aug 2026 22:53:25 +0200 Subject: [PATCH] fix(focus): follow frontmost app when placing new windows - Preserve focus when new windows target persisted workspaces --- .../DefiMacOS/MacOSPlatform+Snapshot.swift | 11 +++-- Sources/DefiMacOS/SnapshotEngine.swift | 4 +- .../DefiRuntime/WindowReconciliation.swift | 11 ++--- .../BackgroundWindowDiscoveryTests.swift | 3 ++ .../PlacementPreferencesTests.swift | 49 +++++++++++++++++++ 5 files changed, 65 insertions(+), 13 deletions(-) diff --git a/Sources/DefiMacOS/MacOSPlatform+Snapshot.swift b/Sources/DefiMacOS/MacOSPlatform+Snapshot.swift index 7986c5c..5e6d2d4 100644 --- a/Sources/DefiMacOS/MacOSPlatform+Snapshot.swift +++ b/Sources/DefiMacOS/MacOSPlatform+Snapshot.swift @@ -38,9 +38,6 @@ extension SnapshotEngine { ) -> DesktopSnapshot { let snapshotStartedAt = ProcessInfo.processInfo.systemUptime let explicitlyDestroyedWindowIDs = consumeExplicitlyDestroyedWindows() - let frontmostProcessID = onMain { - _ in NSWorkspace.shared.frontmostApplication - }?.processIdentifier let tracesWindowTopology = windowTopologyEventPending let capturedTopologyRequiresFullSnapshot = windowTopologyRequiresFullSnapshot @@ -648,7 +645,13 @@ extension SnapshotEngine { "frame commit observed settled=\(settledCommitLatenciesMS.count) deferred=\(deferredMismatchCount) max_latency_ms=\(maximumSettledLatencyMS, format: .fixed(precision: 2))" ) } - let focusedWindowID = focusedWindowID(in: windows) + let frontmostProcessID = onMain { + _ in NSWorkspace.shared.frontmostApplication + }?.processIdentifier + let focusedWindowID = focusedWindowID( + in: windows, + frontmostProcessID: frontmostProcessID + ) lastNativeFocusedWindowID = focusedWindowID verifiedNativeFocusedWindowID = focusedWindowID if let focusedWindowID, diff --git a/Sources/DefiMacOS/SnapshotEngine.swift b/Sources/DefiMacOS/SnapshotEngine.swift index 9da5c58..2effa1e 100644 --- a/Sources/DefiMacOS/SnapshotEngine.swift +++ b/Sources/DefiMacOS/SnapshotEngine.swift @@ -849,9 +849,9 @@ extension SnapshotEngine { } func focusedWindowID( - in windows: [Window] + in windows: [Window], + frontmostProcessID: pid_t? ) -> WindowID? { - let frontmostProcessID = onMain { _ in NSWorkspace.shared.frontmostApplication }?.processIdentifier let system = AXUIElementCreateSystemWide() let focusedApplication: CFTypeRef? = AXMessagingTimeoutAccess.shared .withTimeout( diff --git a/Sources/DefiRuntime/WindowReconciliation.swift b/Sources/DefiRuntime/WindowReconciliation.swift index f15a080..5b0e095 100644 --- a/Sources/DefiRuntime/WindowReconciliation.swift +++ b/Sources/DefiRuntime/WindowReconciliation.swift @@ -63,15 +63,12 @@ public func discoverWindow( else { throw ReducerError.unknownWorkspace(workspaceID) } - // Policy: a managed window spawning into the active workspace of its - // monitor always inserts after the focused column and takes focus - the - // native focused-window event can legitimately lag window creation, so it - // alone must not gate this. + // Policy: a managed window spawned by the frontmost application follows + // its assigned workspace and takes focus. The native focused-window event + // can legitimately lag window creation, so it alone must not gate this. let followsFocus = followFocusIntent - || (isFrontmostAppSpawn - && workspaceID == state.monitors[monitorIndex].activeWorkspace - && !window.floating) + || (isFrontmostAppSpawn && !window.floating) if window.floating && !window.forceTiling { state.monitors[monitorIndex].workspaces[workspaceIndex].floatingWindows.append(window.id) diff --git a/Tests/DefiRuntimeTests/BackgroundWindowDiscoveryTests.swift b/Tests/DefiRuntimeTests/BackgroundWindowDiscoveryTests.swift index 9d89359..762bbdb 100644 --- a/Tests/DefiRuntimeTests/BackgroundWindowDiscoveryTests.swift +++ b/Tests/DefiRuntimeTests/BackgroundWindowDiscoveryTests.swift @@ -19,6 +19,7 @@ struct BackgroundWindowDiscoveryTests { appID: "editor", title: "Selected", frame: Rect(x: 0, y: 0, width: 600, height: 800), + processID: 7, monitorID: monitorID ) let backgroundWindow = Window( @@ -26,6 +27,7 @@ struct BackgroundWindowDiscoveryTests { appID: "proxy", title: "Background", frame: Rect(x: 600, y: 0, width: 600, height: 800), + processID: 42, monitorID: monitorID ) try discoverWindow( @@ -39,6 +41,7 @@ struct BackgroundWindowDiscoveryTests { [selectedWindow, backgroundWindow], config: config, nativeFocusedWindowID: selectedWindow.id, + frontmostProcessID: 7, state: &state ) diff --git a/Tests/DefiRuntimeTests/PlacementPreferencesTests.swift b/Tests/DefiRuntimeTests/PlacementPreferencesTests.swift index 9710fe2..0cd2e13 100644 --- a/Tests/DefiRuntimeTests/PlacementPreferencesTests.swift +++ b/Tests/DefiRuntimeTests/PlacementPreferencesTests.swift @@ -6,6 +6,55 @@ import Testing struct PlacementPreferencesTests { private let monitorID = MonitorID(rawValue: 1) + @Test + func `Frontmost application spawn follows persisted workspace`() throws { + let web = WorkspaceID(rawValue: "web") + let config = Config(workspaces: WorkspacesConfig(names: ["dev", web.rawValue])) + var state = RuntimeState(config: config) + state.attachMonitor(monitorID) + let selected = Window( + id: WindowID(rawValue: 1), + appID: "com.example.Editor", + title: "Editor", + frame: Rect(x: 0, y: 0, width: 600, height: 800), + processID: 7, + monitorID: monitorID + ) + let launched = Window( + id: WindowID(rawValue: 2), + appID: "com.example.Chat", + title: "Chat", + frame: Rect(x: 600, y: 0, width: 600, height: 800), + processID: 42, + monitorID: monitorID + ) + let preferences = PlacementPreferences( + applications: [ + "com.example.chat": WindowPlacementPreference( + workspaceID: web, + monitorID: monitorID + ) + ] + ) + try discoverWindow( + selected, + decision: RuleDecision(followFocus: true), + isNativelyFocused: true, + state: &state + ) + + reconcileWindows( + [selected, launched], + config: config, + placementPreferences: preferences, + frontmostProcessID: 42, + state: &state + ) + + #expect(state.monitors[0].activeWorkspace == web) + #expect(state.selectedWindowID(on: monitorID) == launched.id) + } + @Test func `Reconcile restores persisted application workspace`() throws { let config = Config(workspaces: WorkspacesConfig(names: ["dev", "web"]))