diff --git a/panel/Panel.swift b/panel/Panel.swift index a6cfebe..68eaad6 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -2860,28 +2860,37 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // A blocking permission opened via 'O' (approve:false, "Open editor") // must stay in the panel so the user can still resolve it (Dismiss → // deny); removing it would orphan the hook for ~550s with no recovery - // path. Approvals — and any event without a pending FIFO — are removed - // as before. Stay on the panel if other events remain; otherwise close - // so the system frontmost reverts naturally and the approval keystroke - // lands in the target app's key window (see comment above re: hiding). + // path. Approvals — and any event without a pending FIFO — are removed. if sendApproval || event.fifoPath == nil { store.remove(id: event.id) - if store.events.isEmpty { hidePanel() } } // Approve a blocking permission by writing "allow" to its FIFO; the agent - // then skips its own prompt. Deny is the Dismiss gesture (see - // dismissSelected), NOT this path — so 'O' (approve:false, "Open editor") - // falls through to focusing the editor without resolving the decision. + // then skips its own prompt. This path doesn't hand focus to the editor, + // so keep the panel up while other prompts remain (lets the user triage + // a queue of approvals), closing only once the list empties. Deny is the + // Dismiss gesture (see dismissSelected), NOT this path. if sendApproval, let fifo = event.fifoPath { + if store.events.isEmpty { hidePanel() } DispatchQueue.global(qos: .userInitiated).async { Self.writeFIFO(fifo, "allow") } return } + // Every remaining path hands focus to the source app — either jumping + // to the editor ('O' / a stop event) or sending the approval keystroke + // into its key window. Hide the panel first, unconditionally: our panel + // is floating, pinned, and joins all spaces, so leaving it up keeps + // StackNudge frontmost and the jump silently appears not to happen when + // other events are still listed. Matches focusSelectedSession and the + // banner-click path. The 0.15s settle lets our deactivation land before + // the target is raised (without it an approval keystroke can hit our + // own process instead of the target's key window). guard let bundleID = event.bundleID else { return } + hidePanel() DispatchQueue.global(qos: .userInitiated).async { + Thread.sleep(forTimeInterval: 0.15) AppActivator.activate( bundleID: bundleID, windowTitle: event.windowTitle, @@ -2979,6 +2988,10 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, let ipcHook = VSCodeIntegration.isVSCodeHosted(session.terminalApp) ? session.tabId : nil let sessionID = session.terminalApp?.contains("iTerm") == true ? session.tabId : nil DispatchQueue.global(qos: .userInitiated).async { + // Settle after hidePanel() above so StackNudge has resigned + // frontmost before we raise the target — mirrors actOnSelected + // and the banner-click path. + Thread.sleep(forTimeInterval: 0.15) AppActivator.activate( bundleID: bundleID, windowTitle: session.projectName, diff --git a/shared/AppActivator.swift b/shared/AppActivator.swift index b197e9c..0f6115d 100644 --- a/shared/AppActivator.swift +++ b/shared/AppActivator.swift @@ -66,12 +66,14 @@ struct AppActivator { delay 0.4 do shell script "\(envPrefix)'\(escapedCLI)' --reuse-window '\(escapedPath)'" """)?.executeAndReturnError(&err) + logScriptError(err, "editor-reuse-window") // Step 2: set frontmost (requires Automation for System Events) var err2: NSDictionary? NSAppleScript(source: """ tell application "System Events" to set frontmost of process "\(procName)" to true """)?.executeAndReturnError(&err2) + logScriptError(err2, "editor-set-frontmost") // Step 2.5: AX-raise the specific window. --reuse-window routes // the open request to the right window's IPC server (when @@ -111,6 +113,7 @@ struct AppActivator { end tell end tell """)?.executeAndReturnError(&err3) + logScriptError(err3, "editor-send-enter") } return } @@ -177,6 +180,7 @@ struct AppActivator { end tell end tell """)?.executeAndReturnError(&err) + logScriptError(err, "approve-keystroke") } } @@ -332,6 +336,7 @@ struct AppActivator { """ var err: NSDictionary? NSAppleScript(source: script)?.executeAndReturnError(&err) + logScriptError(err, "ghostty-tab") } // MARK: - iTerm2 (AppleScript bridge) @@ -375,10 +380,29 @@ struct AppActivator { """ var err: NSDictionary? let result = NSAppleScript(source: script)?.executeAndReturnError(&err) - guard err == nil else { return false } + guard err == nil else { + logScriptError(err, "iterm2-session") + return false + } return result?.stringValue == "matched" } + // MARK: - Diagnostics + + // AppleScript failures here are almost always a missing Automation grant: + // macOS wipes the grant whenever the app's cdhash changes (every ad-hoc + // rebuild), and the call then silently no-ops so focus never moves — the + // exact "Open editor did nothing" symptom. Swallowing the error dict hides + // it; surface it to stderr when panel debugging is on so the next + // occurrence is diagnosable. No-op unless STACKNUDGE_PANEL_DEBUG is set. + private static func logScriptError(_ err: NSDictionary?, _ context: String) { + guard let err, + ProcessInfo.processInfo.environment["STACKNUDGE_PANEL_DEBUG"] != nil + else { return } + FileHandle.standardError.write(Data( + "AppActivator[\(context)]: AppleScript error: \(err)\n".utf8)) + } + // MARK: - AX tab switching (standalone terminal apps) // For tabbed terminals where the OS-window is already frontmost but