Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions PolyPilot.Tests/PolyPilot.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
<Compile Include="../PolyPilot/Services/IWsBridgeClient.cs" Link="Shared/IWsBridgeClient.cs" />
<Compile Include="../PolyPilot/Services/IDemoService.cs" Link="Shared/IDemoService.cs" />
<Compile Include="../PolyPilot/Services/INotificationManagerService.cs" Link="Shared/INotificationManagerService.cs" />
<Compile Include="../PolyPilot/Services/IMinimizedModeService.cs" Link="Shared/IMinimizedModeService.cs" />
<Compile Include="../PolyPilot/Services/RepoManager.cs" Link="Shared/RepoManager.cs" />
<Compile Include="../PolyPilot/Services/DemoService.cs" Link="Shared/DemoService.cs" />
<Compile Include="../PolyPilot/Services/CopilotService.cs" Link="Shared/CopilotService.cs" />
Expand Down
9 changes: 9 additions & 0 deletions PolyPilot.Tests/TestStubs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
22 changes: 17 additions & 5 deletions PolyPilot/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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;
}

Expand Down
92 changes: 90 additions & 2 deletions PolyPilot/Components/Pages/Dashboard.razor
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,14 @@
</span>
}
<div class="toolbar-actions">
@if (PlatformHelper.IsDesktop)
{
<button class="always-on-top-btn @(_isAlwaysOnTop ? "active" : "")"
@onclick="ToggleAlwaysOnTop"
title="@(_isAlwaysOnTop ? "Exit always-on-top mode" : "Always on top: focus one session at a time")">
📌
</button>
}
<button class="toolbar-overflow-btn" @onclick="() => toolbarMenuOpen = !toolbarMenuOpen"
@onclick:stopPropagation="true" title="More options">⋯</button>
<div class="toolbar-controls @(toolbarMenuOpen ? "open" : "")">
Expand Down Expand Up @@ -239,7 +247,68 @@
</div>
</div>

@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;
<div class="aot-view">
@if (focusSessions.Count > 1)
{
<div class="aot-queue-badge">+@(focusSessions.Count - 1) more waiting</div>
}
<SessionCard Session="topSession"
@key="@($"aot-{topSession.Name}")"
Meta="topMeta"
IsCompleted="completedSessions.Contains(topSession.Name)"
IsInFocusStrip="true"
ActiveWorkerCount="aotWorkerCount"
MessageCount="@(cardMessageCounts.TryGetValue(topSession.Name, out var aotmc) ? aotmc : DefaultCardMessageWindow)"
MinHeight="@Math.Max(_cardMinHeight, 300)"
StreamingContent="@(streamingBySession.TryGetValue(topSession.Name, out var aots) ? aots : "")"
ActivityText="@(activityBySession.TryGetValue(topSession.Name, out var aota) ? aota : "")"
CurrentToolName="@(currentToolBySession.TryGetValue(topSession.Name, out var aott) ? aott : "")"
ToolActivities="@(toolActivitiesBySession.TryGetValue(topSession.Name, out var aotta) ? aotta : new())"
Intent="@(intentBySession.TryGetValue(topSession.Name, out var aoti) ? aoti : "")"
Error="@(errorBySession.TryGetValue(topSession.Name, out var aote) ? aote : null)"
PendingImages="@(pendingImagesBySession.TryGetValue(topSession.Name, out var aotpi) ? aotpi : new())"
UserAvatarUrl="@CopilotService.GitHubAvatarUrl"
RepoUrl="@CopilotService.GetRepoUrlForSession(topSession.Name)"
Layout="@CopilotService.ChatLayout"
Style="@CopilotService.ChatStyle"
IsRenaming="false"
IsMenuOpen="false"
OnGoTo="() => GoToSession(topSession.Name)"
OnToggleMenu="() => InvokeAsync(StateHasChanged)"
OnExpand="() => ExpandSession(topSession.Name)"
OnLoadMore="() => LoadMoreCardMessages(topSession.Name)"
OnDismissError="() => DismissError(topSession.Name)"
OnClearQueue="() => ClearQueue(topSession.Name)"
OnRemovePendingImage="(idx) => RemovePendingImage(topSession.Name, idx)"
OnSend="(text) => SendFromCard(topSession.Name, text)"
OnStartRename="() => InvokeAsync(StateHasChanged)"
OnCommitRename="(newName) => InvokeAsync(StateHasChanged)"
OnCloseMenu="() => InvokeAsync(StateHasChanged)"
OnRemoveFromFocus="() => CopilotService.RemoveFromFocus(topSession.Name)"
OnPromote="() => CopilotService.PromoteFocusSession(topSession.Name)"
OnDemote="() => CopilotService.DemoteFocusSession(topSession.Name)" />
</div>
}
else
{
<div class="aot-empty">
<span class="aot-empty-icon">✓</span>
<span class="aot-empty-text">All caught up!</span>
<span class="aot-empty-hint">No sessions waiting for attention</span>
</div>
}
}
else @if (expandedSession == null)
{
var focusSessions = CopilotService.GetFocusSessions();
@if (focusSessions.Count > 0)
Expand Down Expand Up @@ -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)
{
<div class="group-divider @(isExternalCollapsed && !hasActiveExternal ? "collapsed" : "")"
@onclick="() => isExternalCollapsed = !isExternalCollapsed">
Expand Down Expand Up @@ -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<string> _expandedWorkerGroups = new(); // group IDs where workers are shown
private bool toolbarMenuOpen;
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand Down
62 changes: 62 additions & 0 deletions PolyPilot/Components/Pages/Dashboard.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
11 changes: 11 additions & 0 deletions PolyPilot/Components/PopupChat/PopupChatHost.razor
Original file line number Diff line number Diff line change
@@ -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. *@

<PopupChatView Request="Request" Service="Service" />

@code {
[Parameter] public PopupRequest Request { get; set; } = null!;
[Parameter] public MinimizedModeService Service { get; set; } = null!;
}
72 changes: 72 additions & 0 deletions PolyPilot/Components/PopupChat/PopupChatView.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
@using PolyPilot.Services

<div class="popup-root">
<div class="popup-header">
<span class="popup-session-name">@Request.SessionName</span>
<div class="popup-header-actions">
<button class="popup-btn-open" @onclick="OpenInFullApp" title="Open in full app">⬡ Open</button>
<button class="popup-btn-dismiss" @onclick="Dismiss" title="Dismiss">✕</button>
</div>
</div>

<div class="popup-response" id="popup-response">
@if (!string.IsNullOrWhiteSpace(Request.LastResponse))
{
<div class="popup-response-text">@Request.LastResponse</div>
}
else
{
<div class="popup-response-empty">Session completed.</div>
}
</div>

<div class="popup-input-row">
<input id="popup-input"
class="popup-input"
type="text"
placeholder="Follow-up message…"
@onkeydown="OnKeyDown"
autofocus />
<button class="popup-btn-send" @onclick="Send" disabled="@_sending">
@if (_sending)
{
<span>…</span>
}
else
{
<span>Send ↵</span>
}
</button>
</div>
</div>

@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<string?>("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);
}
Loading