From ca2e822a648d342b757fc59d952e1e9e9a2aed70 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Tue, 24 Mar 2026 22:57:28 -0500 Subject: [PATCH] feat: Always-on-top mode with minimized popup window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add πŸ“Œ pin button to dashboard toolbar (desktop only) toggling always-on-top mode - In always-on-top mode, show only the top Focus session in a compact AOT view with a queue badge for sessions waiting ('+N more waiting') - Persist IsAlwaysOnTop in UiState; restore window level on app startup - Add MacSleepWakeMonitor: subscribes to NSWorkspace sleep/wake notifications via ObjC P/Invoke so connection health is checked immediately after Mac wake - Add WindowLevelHelper: sets NSWindow level to 3 (floating) via P/Invoke for cross-app always-on-top behavior on Mac Catalyst - Add MinimizedModeService + IMinimizedModeService: manages popup window lifecycle, queues session completions, shows compact popup when main window is not focused - Add PopupChatHost/PopupChatView Blazor components for compact popup chat UI - Add PopupChatPage.xaml/.cs hosting the popup BlazorWebView - Wire popup trigger in CopilotService.Events.cs: fires when EnableMinimizedMode is set and main window is not focused; replaces system notification - Add EnableMinimizedMode to ConnectionSettings (default: false) - Register MinimizedModeService in MauiProgram.cs DI container - Track window focus in App.xaml.cs via Activated/Deactivated events - Add StubMinimizedModeService to test project for DI-safe testing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/PolyPilot.Tests.csproj | 1 + PolyPilot.Tests/TestStubs.cs | 9 ++ PolyPilot/App.xaml.cs | 22 ++- PolyPilot/Components/Pages/Dashboard.razor | 92 ++++++++++- .../Components/Pages/Dashboard.razor.css | 62 ++++++++ .../Components/PopupChat/PopupChatHost.razor | 11 ++ .../Components/PopupChat/PopupChatView.razor | 72 +++++++++ .../PopupChat/PopupChatView.razor.css | 143 ++++++++++++++++++ PolyPilot/MauiProgram.cs | 2 + PolyPilot/Models/ConnectionSettings.cs | 1 + .../MacCatalyst/MacSleepWakeMonitor.cs | 105 +++++++++++++ .../MacCatalyst/WindowLevelHelper.cs | 73 +++++++++ PolyPilot/PopupChatPage.xaml | 9 ++ PolyPilot/PopupChatPage.xaml.cs | 24 +++ PolyPilot/Services/CopilotService.Events.cs | 22 ++- .../Services/CopilotService.Persistence.cs | 5 +- PolyPilot/Services/CopilotService.cs | 1 + PolyPilot/Services/IMinimizedModeService.cs | 19 +++ PolyPilot/Services/MinimizedModeService.cs | 131 ++++++++++++++++ 19 files changed, 791 insertions(+), 13 deletions(-) create mode 100644 PolyPilot/Components/PopupChat/PopupChatHost.razor create mode 100644 PolyPilot/Components/PopupChat/PopupChatView.razor create mode 100644 PolyPilot/Components/PopupChat/PopupChatView.razor.css create mode 100644 PolyPilot/Platforms/MacCatalyst/MacSleepWakeMonitor.cs create mode 100644 PolyPilot/Platforms/MacCatalyst/WindowLevelHelper.cs create mode 100644 PolyPilot/PopupChatPage.xaml create mode 100644 PolyPilot/PopupChatPage.xaml.cs create mode 100644 PolyPilot/Services/IMinimizedModeService.cs create mode 100644 PolyPilot/Services/MinimizedModeService.cs diff --git a/PolyPilot.Tests/PolyPilot.Tests.csproj b/PolyPilot.Tests/PolyPilot.Tests.csproj index 404fb38d..d4bd8658 100644 --- a/PolyPilot.Tests/PolyPilot.Tests.csproj +++ b/PolyPilot.Tests/PolyPilot.Tests.csproj @@ -52,6 +52,7 @@ + diff --git a/PolyPilot.Tests/TestStubs.cs b/PolyPilot.Tests/TestStubs.cs index c0134769..7608bb6c 100644 --- a/PolyPilot.Tests/TestStubs.cs +++ b/PolyPilot.Tests/TestStubs.cs @@ -221,3 +221,12 @@ public async Task SimulateResponseAsync(string sessionName, string prompt, Synch } } #pragma warning restore CS0067 + +internal class StubMinimizedModeService : IMinimizedModeService +{ + public bool IsMainWindowFocused { get; set; } = true; + public List<(string SessionName, string SessionId, string? LastResponse)> Completions { get; } = new(); + + public void OnSessionCompleted(string sessionName, string sessionId, string? lastResponse) + => Completions.Add((sessionName, sessionId, lastResponse)); +} diff --git a/PolyPilot/App.xaml.cs b/PolyPilot/App.xaml.cs index 61a926c5..595400a6 100644 --- a/PolyPilot/App.xaml.cs +++ b/PolyPilot/App.xaml.cs @@ -5,10 +5,12 @@ namespace PolyPilot; public partial class App : Application { private readonly CopilotService _copilotService; + private readonly MinimizedModeService _minimizedModeService; - public App(INotificationManagerService notificationService, CopilotService copilotService) + public App(INotificationManagerService notificationService, CopilotService copilotService, MinimizedModeService minimizedModeService) { _copilotService = copilotService; + _minimizedModeService = minimizedModeService; InitializeComponent(); _ = notificationService.InitializeAsync(); @@ -29,16 +31,26 @@ protected override Window CreateWindow(IActivationState? activationState) { var window = new Window(new MainPage()) { Title = "" }; - // When the window is brought to the foreground (e.g. via AppleScript from a second - // instance that started because macOS resolved a different bundle for a notification - // tap), check whether there is a pending deep-link navigation queued in the sidecar. - window.Activated += (_, _) => CheckPendingNavigation(); + // Track whether the main window is focused for minimized mode popup logic + window.Activated += (_, _) => + { + _minimizedModeService.IsMainWindowFocused = true; + CheckPendingNavigation(); + }; + window.Deactivated += (_, _) => _minimizedModeService.IsMainWindowFocused = false; if (OperatingSystem.IsLinux()) { window.Width = 1400; window.Height = 900; } + +#if MACCATALYST + // Register Mac sleep/wake observer so we reconnect immediately after the Mac wakes, + // even if the user doesn't click on PolyPilot (OnResume only fires on app activation). + PolyPilot.Platforms.MacCatalyst.MacSleepWakeMonitor.Register(_copilotService); +#endif + return window; } diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index 4eb6c6f8..763eecf6 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -192,6 +192,14 @@ }
+ @if (PlatformHelper.IsDesktop) + { + + }
@@ -239,7 +247,68 @@
- @if (expandedSession == null) + @if (_isAlwaysOnTop) + { + var focusSessions = CopilotService.GetFocusSessions(); + var topSession = focusSessions.FirstOrDefault(); + if (topSession != null) + { + var topMeta = CopilotService.GetSessionMeta(topSession.Name); + var aotWorkerCount = (topMeta?.Role == MultiAgentRole.Orchestrator && topMeta?.GroupId != null) + ? CopilotService.GetMultiAgentProgress(topMeta.GroupId).Processing + : 0; +
+ @if (focusSessions.Count > 1) + { +
+@(focusSessions.Count - 1) more waiting
+ } + +
+ } + else + { +
+ βœ“ + All caught up! + No sessions waiting for attention +
+ } + } + else @if (expandedSession == null) { var focusSessions = CopilotService.GetFocusSessions(); @if (focusSessions.Count > 0) @@ -439,7 +508,7 @@ var externalSessions = CopilotService.ExternalSessions.ToList(); var hasActiveExternal = externalSessions.Any(s => s.IsActive); } - @if (externalSessions.Count > 0 && expandedSession == null) + @if (externalSessions.Count > 0 && expandedSession == null && !_isAlwaysOnTop) {
@@ -500,6 +569,7 @@ private bool isCompactGrid; // true = compact cards, false = spacious cards private int _gridColumns = 3; // number of cards per row (2-6) private int _cardMinHeight = 250; // card minimum height in px (150-600, step 50) + private bool _isAlwaysOnTop = false; // always-on-top focus mode private bool isExternalCollapsed = true; // external sessions collapsed by default private HashSet _expandedWorkerGroups = new(); // group IDs where workers are shown private bool toolbarMenuOpen; @@ -595,6 +665,15 @@ // Restore card min height (default 250, range 150-600) _cardMinHeight = uiState.CardMinHeight is >= 150 and <= 600 ? uiState.CardMinHeight : 250; + + // Restore always-on-top mode + _isAlwaysOnTop = uiState.IsAlwaysOnTop; + if (_isAlwaysOnTop) + { +#if MACCATALYST + PolyPilot.Platforms.MacCatalyst.WindowLevelHelper.SetAlwaysOnTop(true); +#endif + } // Restore expanded session state if (!string.IsNullOrEmpty(uiState.ExpandedSession)) @@ -2814,6 +2893,15 @@ CopilotService.SaveUiState("/dashboard", activeSession: null, expandedSession: null, expandedGrid: !isCompactGrid); } + private void ToggleAlwaysOnTop() + { + _isAlwaysOnTop = !_isAlwaysOnTop; + CopilotService.SaveUiState("/dashboard", isAlwaysOnTop: _isAlwaysOnTop); +#if MACCATALYST + PolyPilot.Platforms.MacCatalyst.WindowLevelHelper.SetAlwaysOnTop(_isAlwaysOnTop); +#endif + } + private void IncrementGridColumns() { if (_gridColumns < 6) diff --git a/PolyPilot/Components/Pages/Dashboard.razor.css b/PolyPilot/Components/Pages/Dashboard.razor.css index 240b47dc..1ee947c0 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor.css +++ b/PolyPilot/Components/Pages/Dashboard.razor.css @@ -1581,3 +1581,65 @@ .ma-expanded-toolbar-input::placeholder { color: var(--text-muted); } + +/* Always On Top mode */ +.always-on-top-btn { + background: transparent; + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 4px 8px; + cursor: pointer; + font-size: var(--type-body); + color: var(--text-secondary); + transition: all 0.15s; + opacity: 0.7; +} +.always-on-top-btn:hover { + background: var(--hover-bg); + opacity: 1; +} +.always-on-top-btn.active { + background: rgba(99, 102, 241, 0.2); + border-color: rgba(99, 102, 241, 0.5); + color: rgba(120, 100, 240, 1); + opacity: 1; +} + +/* Always On Top compact view */ +.aot-view { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 0; +} +.aot-queue-badge { + font-size: var(--type-callout); + color: var(--text-secondary); + background: rgba(99, 102, 241, 0.1); + border: 1px solid rgba(99, 102, 241, 0.2); + border-radius: 12px; + padding: 2px 10px; + align-self: flex-start; + margin-left: 4px; +} +.aot-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 60px 20px; + color: var(--text-secondary); +} +.aot-empty-icon { + font-size: var(--type-title1); + color: rgba(99, 102, 241, 0.7); +} +.aot-empty-text { + font-size: var(--type-title2); + font-weight: 600; + color: var(--text-primary); +} +.aot-empty-hint { + font-size: var(--type-callout); +} diff --git a/PolyPilot/Components/PopupChat/PopupChatHost.razor b/PolyPilot/Components/PopupChat/PopupChatHost.razor new file mode 100644 index 00000000..1c8d4bf9 --- /dev/null +++ b/PolyPilot/Components/PopupChat/PopupChatHost.razor @@ -0,0 +1,11 @@ +@using PolyPilot.Services + +@* Root component for the minimized mode popup window. + Mounted directly via RootComponent (no router) so it owns the full #app div. *@ + + + +@code { + [Parameter] public PopupRequest Request { get; set; } = null!; + [Parameter] public MinimizedModeService Service { get; set; } = null!; +} diff --git a/PolyPilot/Components/PopupChat/PopupChatView.razor b/PolyPilot/Components/PopupChat/PopupChatView.razor new file mode 100644 index 00000000..e6bb8fc9 --- /dev/null +++ b/PolyPilot/Components/PopupChat/PopupChatView.razor @@ -0,0 +1,72 @@ +@using PolyPilot.Services + + + +@code { + [Parameter] public PopupRequest Request { get; set; } = null!; + [Parameter] public MinimizedModeService Service { get; set; } = null!; + + [Inject] private IJSRuntime JS { get; set; } = null!; + + private bool _sending; + + private async Task Send() + { + var prompt = await JS.InvokeAsync("eval", "document.getElementById('popup-input')?.value?.trim()"); + if (string.IsNullOrWhiteSpace(prompt)) return; + + _sending = true; + StateHasChanged(); + + Service.SendAndDismiss(Request.SessionName, prompt); + } + + private async Task OnKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter" && !e.ShiftKey) + await Send(); + else if (e.Key == "Escape") + Dismiss(); + } + + private void Dismiss() => Service.DismissPopup(); + private void OpenInFullApp() => Service.OpenInFullApp(Request.SessionName); +} diff --git a/PolyPilot/Components/PopupChat/PopupChatView.razor.css b/PolyPilot/Components/PopupChat/PopupChatView.razor.css new file mode 100644 index 00000000..1c33976a --- /dev/null +++ b/PolyPilot/Components/PopupChat/PopupChatView.razor.css @@ -0,0 +1,143 @@ +/* Popup Chat View β€” compact UI for minimized mode popup window */ + +.popup-root { + display: flex; + flex-direction: column; + height: 100vh; + background: #0f0f22; + color: #e8e8f0; + font-family: var(--font-base); + font-size: var(--type-body, 0.85rem); +} + +/* Header */ +.popup-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px 10px; + background: #16162a; + border-bottom: 1px solid rgba(255,255,255,0.08); + gap: 8px; +} + +.popup-session-name { + font-size: var(--type-title3, 1.0rem); + font-weight: 600; + color: #c8b4f8; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.popup-header-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +.popup-btn-open { + background: rgba(120, 80, 220, 0.2); + border: 1px solid rgba(120, 80, 220, 0.4); + color: #c8b4f8; + border-radius: 6px; + padding: 4px 10px; + font-size: var(--type-callout, 0.8rem); + cursor: pointer; + transition: background 0.15s; +} + +.popup-btn-open:hover { + background: rgba(120, 80, 220, 0.35); +} + +.popup-btn-dismiss { + background: transparent; + border: 1px solid rgba(255,255,255,0.15); + color: #888; + border-radius: 6px; + padding: 4px 8px; + font-size: var(--type-callout, 0.8rem); + cursor: pointer; + transition: all 0.15s; +} + +.popup-btn-dismiss:hover { + background: rgba(255,255,255,0.08); + color: #ccc; +} + +/* Response area */ +.popup-response { + flex: 1; + overflow-y: auto; + padding: 16px; + line-height: 1.6; +} + +.popup-response-text { + white-space: pre-wrap; + word-break: break-word; + color: #d0d0e8; + font-size: var(--type-body, 0.85rem); +} + +.popup-response-empty { + color: #666; + font-style: italic; + font-size: var(--type-body, 0.85rem); +} + +/* Input row */ +.popup-input-row { + display: flex; + gap: 8px; + padding: 12px 16px 14px; + border-top: 1px solid rgba(255,255,255,0.08); + background: #12122a; +} + +.popup-input { + flex: 1; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 8px; + padding: 8px 12px; + color: #e8e8f0; + font-size: var(--type-body, 0.85rem); + font-family: inherit; + outline: none; + transition: border-color 0.15s; +} + +.popup-input:focus { + border-color: rgba(120, 80, 220, 0.6); + background: rgba(255,255,255,0.08); +} + +.popup-input::placeholder { + color: #555; +} + +.popup-btn-send { + background: rgba(120, 80, 220, 0.8); + border: none; + border-radius: 8px; + padding: 8px 14px; + color: #fff; + font-size: var(--type-body, 0.85rem); + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s; +} + +.popup-btn-send:hover:not(:disabled) { + background: rgba(120, 80, 220, 1.0); +} + +.popup-btn-send:disabled { + opacity: 0.5; + cursor: default; +} diff --git a/PolyPilot/MauiProgram.cs b/PolyPilot/MauiProgram.cs index b313f32a..06caa891 100644 --- a/PolyPilot/MauiProgram.cs +++ b/PolyPilot/MauiProgram.cs @@ -116,6 +116,8 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(SpeechToText.Default); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); #if DEBUG builder.Services.AddBlazorWebViewDeveloperTools(); diff --git a/PolyPilot/Models/ConnectionSettings.cs b/PolyPilot/Models/ConnectionSettings.cs index 1fd653b6..4df4276f 100644 --- a/PolyPilot/Models/ConnectionSettings.cs +++ b/PolyPilot/Models/ConnectionSettings.cs @@ -120,6 +120,7 @@ public string? ServerPassword public PluginSettings Plugins { get; set; } = new(); public bool EnableSessionNotifications { get; set; } = false; public bool MuteWorkerNotifications { get; set; } = false; + public bool EnableMinimizedMode { get; set; } = false; public bool CodespacesEnabled { get; set; } = false; /// /// When true, logs every SDK event type to event-diagnostics.log (not just lifecycle events). diff --git a/PolyPilot/Platforms/MacCatalyst/MacSleepWakeMonitor.cs b/PolyPilot/Platforms/MacCatalyst/MacSleepWakeMonitor.cs new file mode 100644 index 00000000..7574f5ea --- /dev/null +++ b/PolyPilot/Platforms/MacCatalyst/MacSleepWakeMonitor.cs @@ -0,0 +1,105 @@ +using System.Runtime.InteropServices; +using Foundation; +using ObjCRuntime; +using PolyPilot.Services; + +namespace PolyPilot.Platforms.MacCatalyst; + +/// +/// Subscribes to NSWorkspace sleep/wake notifications so PolyPilot can +/// proactively recover the copilot connection after the Mac wakes from sleep. +/// +/// On Mac Catalyst, App.OnResume() fires when the *app* is re-activated but +/// NOT reliably when the Mac wakes from system sleep without the user first +/// clicking on PolyPilot. NSWorkspace DidWake fires immediately after the Mac +/// wakes, regardless of which app has focus. +/// +/// NSWorkspaceDidWakeNotification must be observed via NSWorkspace.sharedWorkspace.notificationCenter, +/// NOT NSNotificationCenter.defaultCenter. NSWorkspace is not in the Mac Catalyst .NET binding, +/// so we access it via ObjC messaging and wrap the result as a managed NSNotificationCenter. +/// +public static class MacSleepWakeMonitor +{ + [DllImport("/usr/lib/libobjc.dylib", EntryPoint = "objc_msgSend")] + private static extern IntPtr IntPtr_objc_msgSend(IntPtr receiver, IntPtr selector); + + private static NSObject? _wakeObserver; + private static NSObject? _sleepObserver; + private static CopilotService? _copilotService; + + /// + /// Returns NSWorkspace.sharedWorkspace.notificationCenter as a managed NSNotificationCenter. + /// NSWorkspace is AppKit-only and has no direct .NET binding on Mac Catalyst. + /// + private static NSNotificationCenter? GetWorkspaceNotificationCenter() + { + try + { + var nsWorkspaceClass = Class.GetHandle("NSWorkspace"); + if (nsWorkspaceClass == IntPtr.Zero) return null; + var shared = IntPtr_objc_msgSend(nsWorkspaceClass, Selector.GetHandle("sharedWorkspace")); + if (shared == IntPtr.Zero) return null; + var centerPtr = IntPtr_objc_msgSend(shared, Selector.GetHandle("notificationCenter")); + if (centerPtr == IntPtr.Zero) return null; + return Runtime.GetNSObject(centerPtr); + } + catch (Exception ex) + { + Console.WriteLine($"[SleepWake] Failed to get NSWorkspace notificationCenter: {ex.Message}"); + return null; + } + } + + public static void Register(CopilotService copilotService) + { + _copilotService = copilotService; + + // NSWorkspaceDidWakeNotification and NSWorkspaceWillSleepNotification must be observed + // via NSWorkspace.sharedWorkspace.notificationCenter β€” not NSNotificationCenter.defaultCenter. + var notifCenter = GetWorkspaceNotificationCenter() ?? NSNotificationCenter.DefaultCenter; + + // Wake: Mac has just woken from sleep β€” reconnect immediately + _wakeObserver = notifCenter.AddObserver( + new NSString("NSWorkspaceDidWakeNotification"), + null, + NSOperationQueue.MainQueue, + OnDidWake); + + // Sleep (optional): log so we can correlate with subsequent wake + _sleepObserver = notifCenter.AddObserver( + new NSString("NSWorkspaceWillSleepNotification"), + null, + NSOperationQueue.MainQueue, + OnWillSleep); + + Console.WriteLine("[SleepWake] NSWorkspace sleep/wake observer registered"); + } + + public static void Unregister() + { + var notifCenter = GetWorkspaceNotificationCenter() ?? NSNotificationCenter.DefaultCenter; + if (_wakeObserver != null) + { + notifCenter.RemoveObserver(_wakeObserver); + _wakeObserver = null; + } + if (_sleepObserver != null) + { + notifCenter.RemoveObserver(_sleepObserver); + _sleepObserver = null; + } + } + + private static void OnWillSleep(NSNotification notification) + { + Console.WriteLine("[SleepWake] Mac going to sleep β€” connection may drop"); + } + + private static void OnDidWake(NSNotification notification) + { + Console.WriteLine("[SleepWake] Mac woke from sleep β€” triggering connection health check"); + var svc = _copilotService; + if (svc != null) + Task.Run(async () => await svc.CheckConnectionHealthAsync()).ContinueWith(_ => { }); + } +} diff --git a/PolyPilot/Platforms/MacCatalyst/WindowLevelHelper.cs b/PolyPilot/Platforms/MacCatalyst/WindowLevelHelper.cs new file mode 100644 index 00000000..5c4340ce --- /dev/null +++ b/PolyPilot/Platforms/MacCatalyst/WindowLevelHelper.cs @@ -0,0 +1,73 @@ +using System.Runtime.InteropServices; +using ObjCRuntime; +using UIKit; + +namespace PolyPilot.Platforms.MacCatalyst; + +/// +/// Sets the Mac Catalyst window to "always on top" by messaging the underlying NSWindow +/// directly via the ObjC runtime. UIWindow.WindowLevel only reorders windows within the +/// app; to float above ALL apps we must set the NSWindow level to NSFloatingWindowLevel (3). +/// +/// The NSWindow is retrieved by sending the [nsWindow] message to the UIWindow (Mac Catalyst +/// private bridge β€” works on all tested Catalyst versions). +/// +/// CGWindowLevel constants: +/// NSNormalWindowLevel = 0 (regular apps) +/// NSFloatingWindowLevel = 3 (always above normal windows, below Dock/menu bar) +/// +public static class WindowLevelHelper +{ + // NSFloatingWindowLevel = 3: floats above all normal app windows + private const nint FloatingLevel = 3; + private const nint NormalLevel = 0; + + // Retrieve an ObjC object: id objc_msgSend(id self, SEL op) + [DllImport("/usr/lib/libobjc.dylib", EntryPoint = "objc_msgSend")] + private static extern IntPtr IntPtr_objc_msgSend(IntPtr receiver, IntPtr selector); + + // Set NSWindow level: void objc_msgSend(id self, SEL op, NSInteger level) + [DllImport("/usr/lib/libobjc.dylib", EntryPoint = "objc_msgSend")] + private static extern void void_objc_msgSend_nint(IntPtr receiver, IntPtr selector, nint arg); + + public static void SetAlwaysOnTop(bool onTop) + { + try + { + var uiWindow = UIApplication.SharedApplication.ConnectedScenes + .OfType() + .SelectMany(s => s.Windows) + .FirstOrDefault(w => w.IsKeyWindow) + ?? UIApplication.SharedApplication.ConnectedScenes + .OfType() + .SelectMany(s => s.Windows) + .FirstOrDefault(); + + if (uiWindow == null) + { + Console.WriteLine("[WindowLevel] No UIWindow found"); + return; + } + + // Get the underlying NSWindow via [uiWindow nsWindow] β€” Catalyst private bridge + IntPtr uiWindowHandle = uiWindow.Handle; + IntPtr nsWindowPtr = IntPtr_objc_msgSend(uiWindowHandle, Selector.GetHandle("nsWindow")); + + if (nsWindowPtr == IntPtr.Zero) + { + Console.WriteLine("[WindowLevel] [nsWindow] returned nil β€” cannot set level"); + return; + } + + // [nsWindow setLevel: NSFloatingWindowLevel (3)] β€” floats above all other apps + nint level = onTop ? FloatingLevel : NormalLevel; + void_objc_msgSend_nint(nsWindowPtr, Selector.GetHandle("setLevel:"), level); + + Console.WriteLine($"[WindowLevel] NSWindow.setLevel:{level} ({(onTop ? "floating" : "normal")})"); + } + catch (Exception ex) + { + Console.WriteLine($"[WindowLevel] SetAlwaysOnTop failed: {ex.Message}"); + } + } +} diff --git a/PolyPilot/PopupChatPage.xaml b/PolyPilot/PopupChatPage.xaml new file mode 100644 index 00000000..599eb16b --- /dev/null +++ b/PolyPilot/PopupChatPage.xaml @@ -0,0 +1,9 @@ + + + + + + diff --git a/PolyPilot/PopupChatPage.xaml.cs b/PolyPilot/PopupChatPage.xaml.cs new file mode 100644 index 00000000..2eb41c2a --- /dev/null +++ b/PolyPilot/PopupChatPage.xaml.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Components.WebView.Maui; +using PolyPilot.Components.PopupChat; +using PolyPilot.Services; + +namespace PolyPilot; + +public partial class PopupChatPage : ContentPage +{ + public PopupChatPage(PopupRequest request, MinimizedModeService service) + { + InitializeComponent(); + + blazorWebView.RootComponents.Add(new RootComponent + { + Selector = "#app", + ComponentType = typeof(PopupChatHost), + Parameters = new Dictionary + { + ["Request"] = request, + ["Service"] = service, + }, + }); + } +} diff --git a/PolyPilot/Services/CopilotService.Events.cs b/PolyPilot/Services/CopilotService.Events.cs index 48b5b28f..1053c728 100644 --- a/PolyPilot/Services/CopilotService.Events.cs +++ b/PolyPilot/Services/CopilotService.Events.cs @@ -681,15 +681,29 @@ void Invoke(Action action) try { var currentSettings = ConnectionSettings.Load(); + var isWorker = currentSettings.MuteWorkerNotifications && IsWorkerInMultiAgentGroup(sessionName); + var lastMsg = state.Info.History.LastOrDefault(m => m.Role == "assistant"); + + // Minimized mode popup (desktop only, fires when main window is not focused) + if (currentSettings.EnableMinimizedMode && !isWorker && PlatformHelper.IsDesktop) + { + var minimizedService = _serviceProvider?.GetService(); + if (minimizedService != null && !minimizedService.IsMainWindowFocused) + { + var body = BuildNotificationBody(lastMsg?.Content, state.Info.History.Count); + minimizedService.OnSessionCompleted(sessionName, state.Info.SessionId ?? "", body); + return; // popup replaces system notification + } + } + if (!currentSettings.EnableSessionNotifications) return; - if (currentSettings.MuteWorkerNotifications && IsWorkerInMultiAgentGroup(sessionName)) return; + if (isWorker) return; var notifService = _serviceProvider?.GetService(); if (notifService == null || !notifService.HasPermission) return; - var lastMsg = state.Info.History.LastOrDefault(m => m.Role == "assistant"); - var body = BuildNotificationBody(lastMsg?.Content, state.Info.History.Count); + var body2 = BuildNotificationBody(lastMsg?.Content, state.Info.History.Count); await notifService.SendNotificationAsync( $"βœ“ {sessionName}", - body, + body2, state.Info.SessionId); } catch { } diff --git a/PolyPilot/Services/CopilotService.Persistence.cs b/PolyPilot/Services/CopilotService.Persistence.cs index 2c2793a0..81a85a20 100644 --- a/PolyPilot/Services/CopilotService.Persistence.cs +++ b/PolyPilot/Services/CopilotService.Persistence.cs @@ -746,7 +746,7 @@ public async Task RestorePreviousSessionsAsync(CancellationToken cancellationTok } - public void SaveUiState(string currentPage, string? activeSession = null, int? fontSize = null, string? selectedModel = null, bool? expandedGrid = null, string? expandedSession = "<>", Dictionary? inputModes = null, int? gridColumns = null, int? cardMinHeight = null) + public void SaveUiState(string currentPage, string? activeSession = null, int? fontSize = null, string? selectedModel = null, bool? expandedGrid = null, string? expandedSession = "<>", Dictionary? inputModes = null, int? gridColumns = null, int? cardMinHeight = null, bool? isAlwaysOnTop = null) { try { @@ -766,7 +766,8 @@ public void SaveUiState(string currentPage, string? activeSession = null, int? f : existing?.InputModes ?? new Dictionary(), CompletedTutorials = existing?.CompletedTutorials ?? new HashSet(), GridColumns = gridColumns ?? existing?.GridColumns ?? 3, - CardMinHeight = cardMinHeight ?? existing?.CardMinHeight ?? 250 + CardMinHeight = cardMinHeight ?? existing?.CardMinHeight ?? 250, + IsAlwaysOnTop = isAlwaysOnTop ?? existing?.IsAlwaysOnTop ?? false, }; lock (_uiStateLock) diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 4e6d16d0..5ed6b8df 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -4604,6 +4604,7 @@ public class UiState public HashSet CompletedTutorials { get; set; } = new(); public int GridColumns { get; set; } = 3; public int CardMinHeight { get; set; } = 250; + public bool IsAlwaysOnTop { get; set; } = false; } public class ActiveSessionEntry diff --git a/PolyPilot/Services/IMinimizedModeService.cs b/PolyPilot/Services/IMinimizedModeService.cs new file mode 100644 index 00000000..5bb0d32a --- /dev/null +++ b/PolyPilot/Services/IMinimizedModeService.cs @@ -0,0 +1,19 @@ +namespace PolyPilot.Services; + +/// +/// Manages the minimized popup window that appears when a session completes while +/// the main PolyPilot window is not focused. +/// +public interface IMinimizedModeService +{ + /// + /// Whether the main PolyPilot window is currently focused. + /// + bool IsMainWindowFocused { get; set; } + + /// + /// Called when a session completes while the main window is not focused. + /// Queues the popup and opens it if none is currently active. + /// + void OnSessionCompleted(string sessionName, string sessionId, string? lastResponse); +} diff --git a/PolyPilot/Services/MinimizedModeService.cs b/PolyPilot/Services/MinimizedModeService.cs new file mode 100644 index 00000000..fa973ebd --- /dev/null +++ b/PolyPilot/Services/MinimizedModeService.cs @@ -0,0 +1,131 @@ +using System.Collections.Concurrent; + +namespace PolyPilot.Services; + +/// +/// Manages the minimized popup window that appears when a session completes while +/// the main PolyPilot window is not focused. Allows the user to send a quick +/// follow-up prompt from the popup without switching to the full app. +/// +public class MinimizedModeService : IMinimizedModeService +{ + private readonly CopilotService _copilotService; + + private readonly ConcurrentQueue _pendingPopups = new(); + private Window? _activePopupWindow; + private bool _isShowingPopup; // accessed only from main thread + + public bool IsMainWindowFocused { get; set; } = true; + + public MinimizedModeService(CopilotService copilotService) + { + _copilotService = copilotService; + } + + /// + /// Called when a session completes while the main window is not focused. + /// Queues the popup and opens it if none is currently active. + /// + public void OnSessionCompleted(string sessionName, string sessionId, string? lastResponse) + { + _pendingPopups.Enqueue(new PopupRequest(sessionName, sessionId, lastResponse ?? "")); + TryShowNextPopup(); + } + + private void TryShowNextPopup() + { + // Ensure all state access and window operations happen on the main thread. + // OnSessionCompleted can be called from background threads (SDK event callbacks), + // so we always marshal here before touching _isShowingPopup. + if (!MainThread.IsMainThread) + { + MainThread.BeginInvokeOnMainThread(TryShowNextPopup); + return; + } + + if (_isShowingPopup) return; + if (!_pendingPopups.TryDequeue(out var request)) return; + + _isShowingPopup = true; + +#if MACCATALYST || WINDOWS + var page = new PopupChatPage(request, this); + var window = new Window(page) + { + Title = $"PolyPilot β€” {request.SessionName}", + Width = 520, + Height = 420, + }; + + window.Destroying += (_, _) => + { + if (ReferenceEquals(_activePopupWindow, window)) + { + _activePopupWindow = null; + _isShowingPopup = false; + TryShowNextPopup(); + } + }; + + _activePopupWindow = window; + Application.Current?.OpenWindow(window); +#endif + } + + /// + /// Closes the active popup window and shows the next one if queued. + /// + public void DismissPopup() + { + MainThread.BeginInvokeOnMainThread(() => + { + if (_activePopupWindow != null) + { + Application.Current?.CloseWindow(_activePopupWindow); + // The Destroying event will reset _isShowingPopup and call TryShowNextPopup + } + }); + } + + /// + /// Sends a prompt to the session, then closes the popup. + /// + public void SendAndDismiss(string sessionName, string prompt) + { + if (string.IsNullOrWhiteSpace(prompt)) return; + + _ = Task.Run(async () => + { + try + { + await _copilotService.SendPromptAsync(sessionName, prompt); + } + catch + { + // Best effort β€” session may have been closed + } + }); + + DismissPopup(); + + // Switch to the session in the main window + MainThread.BeginInvokeOnMainThread(() => + { + _copilotService.SwitchSession(sessionName); + }); + } + + /// + /// Opens the main window and navigates to the session, then closes the popup. + /// + public void OpenInFullApp(string sessionName) + { + MainThread.BeginInvokeOnMainThread(() => + { + _copilotService.SwitchSession(sessionName); + DismissPopup(); + }); + } +} + +public record PopupRequest(string SessionName, string SessionId, string LastResponse);