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)
+ {
+
+ }
- @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)
{
isExternalCollapsed = !isExternalCollapsed">
@@ -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);