From 5114e0826a941051f4b8ad72ac36fb085dd5fe0f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 17:38:45 +0000 Subject: [PATCH 1/3] Initial plan From c945076b4dc9c36dfcddb5c4346a2bf14256709e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 17:54:10 +0000 Subject: [PATCH 2/3] Add per-session notifications and timer-based reminders Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- PolyPilot.Tests/AgentSessionInfoTests.cs | 18 ++++++++ PolyPilot.Tests/ConnectionSettingsTests.cs | 42 ++++++++++++++++++ .../Components/Layout/SessionListItem.razor | 16 +++++++ PolyPilot/Components/Pages/Settings.razor | 26 +++++++++++ PolyPilot/Components/SessionCard.razor | 16 +++++++ PolyPilot/Models/AgentSessionInfo.cs | 6 +++ PolyPilot/Models/ConnectionSettings.cs | 6 +++ PolyPilot/Services/CopilotService.Events.cs | 44 ++++++++++++++++++- PolyPilot/Services/CopilotService.cs | 17 +++++++ 9 files changed, 190 insertions(+), 1 deletion(-) diff --git a/PolyPilot.Tests/AgentSessionInfoTests.cs b/PolyPilot.Tests/AgentSessionInfoTests.cs index 133d2ff3..4176b3c1 100644 --- a/PolyPilot.Tests/AgentSessionInfoTests.cs +++ b/PolyPilot.Tests/AgentSessionInfoTests.cs @@ -167,4 +167,22 @@ public void UnreadCount_HandlesLastReadBeyondHistory() Assert.Equal(0, session.UnreadCount); } + + [Fact] + public void NotifyOnComplete_DefaultsFalse() + { + var session = new AgentSessionInfo { Name = "test", Model = "gpt-5" }; + Assert.False(session.NotifyOnComplete); + } + + [Fact] + public void NotifyOnComplete_CanBeSetAndCleared() + { + var session = new AgentSessionInfo { Name = "test", Model = "gpt-5" }; + session.NotifyOnComplete = true; + Assert.True(session.NotifyOnComplete); + + session.NotifyOnComplete = false; + Assert.False(session.NotifyOnComplete); + } } diff --git a/PolyPilot.Tests/ConnectionSettingsTests.cs b/PolyPilot.Tests/ConnectionSettingsTests.cs index 9df2ed20..55479e1e 100644 --- a/PolyPilot.Tests/ConnectionSettingsTests.cs +++ b/PolyPilot.Tests/ConnectionSettingsTests.cs @@ -391,6 +391,48 @@ public void NormalizeRemoteUrl_DoesNotDoubleScheme() Assert.Equal("http://http://example.com", result); } + [Fact] + public void EnableSessionNotifications_DefaultsFalse() + { + var settings = new ConnectionSettings(); + Assert.False(settings.EnableSessionNotifications); + } + + [Fact] + public void NotificationReminderIntervalMinutes_DefaultsZero() + { + var settings = new ConnectionSettings(); + Assert.Equal(0, settings.NotificationReminderIntervalMinutes); + } + + [Fact] + public void NotificationReminderIntervalMinutes_CanBeSet() + { + var settings = new ConnectionSettings { NotificationReminderIntervalMinutes = 5 }; + Assert.Equal(5, settings.NotificationReminderIntervalMinutes); + } + + [Fact] + public void NotificationReminderIntervalMinutes_RoundTripsViaSerialization() + { + var original = new ConnectionSettings { NotificationReminderIntervalMinutes = 10 }; + var json = JsonSerializer.Serialize(original); + var loaded = JsonSerializer.Deserialize(json); + + Assert.NotNull(loaded); + Assert.Equal(10, loaded!.NotificationReminderIntervalMinutes); + } + + [Fact] + public void NotificationReminderIntervalMinutes_BackwardCompatibility_DefaultsZero() + { + var json = """{"Mode":0,"Host":"localhost","Port":4321}"""; + var loaded = JsonSerializer.Deserialize(json); + + Assert.NotNull(loaded); + Assert.Equal(0, loaded!.NotificationReminderIntervalMinutes); + } + private void Dispose() { try { Directory.Delete(_testDir, true); } catch { } diff --git a/PolyPilot/Components/Layout/SessionListItem.razor b/PolyPilot/Components/Layout/SessionListItem.razor index 46bbeccb..9a2f8a73 100644 --- a/PolyPilot/Components/Layout/SessionListItem.razor +++ b/PolyPilot/Components/Layout/SessionListItem.razor @@ -33,6 +33,10 @@ { πŸ“Œ } + @if (Session.NotifyOnComplete) + { + πŸ”” + } @if (IsCompleted) { βœ… @@ -113,6 +117,18 @@ πŸ“Œ Pin } + @if (Session.NotifyOnComplete) + { + + } + else + { + + } @if (Groups != null && Groups.Count > 1) { diff --git a/PolyPilot/Components/Pages/Settings.razor b/PolyPilot/Components/Pages/Settings.razor index 95d061a0..88570ad0 100644 --- a/PolyPilot/Components/Pages/Settings.razor +++ b/PolyPilot/Components/Pages/Settings.razor @@ -515,6 +515,23 @@

Get a system notification when an agent finishes responding

+ @if (settings.EnableSessionNotifications) + { +
+ +

Send a "still running" reminder while a session is processing

+
+ } @@ -1017,6 +1034,15 @@ } } + private void OnReminderIntervalChanged(ChangeEventArgs e) + { + if (int.TryParse(e.Value?.ToString(), out var minutes)) + { + settings.NotificationReminderIntervalMinutes = minutes; + settings.Save(); + } + } + private void ToggleAutoUpdate() { if (GitAutoUpdate.IsEnabled) diff --git a/PolyPilot/Components/SessionCard.razor b/PolyPilot/Components/SessionCard.razor index ca4bfba9..d30118cd 100644 --- a/PolyPilot/Components/SessionCard.razor +++ b/PolyPilot/Components/SessionCard.razor @@ -10,6 +10,10 @@ { πŸ“Œ } + @if (Session.NotifyOnComplete) + { + πŸ”” + } @if (IsRenaming) { @@ -49,6 +53,18 @@ πŸ“Œ Pin } + @if (Session.NotifyOnComplete) + { + + } + else + { + + }
} + @if (Groups != null && Groups.Count > 1) { @@ -244,6 +247,7 @@ [Parameter] public EventCallback OnCloseMenu { get; set; } [Parameter] public EventCallback OnReportBug { get; set; } [Parameter] public EventCallback OnFixWithCopilot { get; set; } + [Parameter] public EventCallback OnSchedulePrompt { get; set; } /// /// Show close confirmation via a JS-created dialog (outside Blazor's DOM tree). diff --git a/PolyPilot/Components/Layout/SessionSidebar.razor b/PolyPilot/Components/Layout/SessionSidebar.razor index 232d2f57..2f314ba3 100644 --- a/PolyPilot/Components/Layout/SessionSidebar.razor +++ b/PolyPilot/Components/Layout/SessionSidebar.razor @@ -2,6 +2,7 @@ @using PolyPilot.Models @inject CopilotService CopilotService @inject RepoManager RepoManager +@inject ScheduledPromptService ScheduledPrompts @inject IJSRuntime JS @inject NavigationManager Nav @implements IDisposable @@ -144,6 +145,85 @@ else } + else if (showSchedule) + { +
+
+ πŸ“… Schedule a Prompt + +
+ + + +
+ + +
+ @if (scheduleType == "delay") + { +
+ + +
+ } + else + { +
+ + +
+ } +
+ @if (!string.IsNullOrEmpty(scheduleStatus)) + { + @scheduleStatus + } + +
+ @{ + var sessionSchedules = ScheduledPrompts.Prompts.Where(p => p.SessionName == scheduleSessionName).ToList(); + } + @if (sessionSchedules.Any()) + { +
+
Scheduled for @scheduleSessionName
+ @foreach (var sp in sessionSchedules) + { + var spId = sp.Id; +
+ @sp.DisplayName + @FormatNextRun(sp) + +
+ } +
+ } +
+ } else { + + + + +
+ + +
+ @if (_scheduleType == "delay") + { +
+ + +
+ } + else + { +
+ + +
+ } + @if (!string.IsNullOrEmpty(_scheduleStatus)) + { +
@_scheduleStatus
+ } +
+ +
+ @{ + var existing = ScheduledPrompts.Prompts.Where(p => p.SessionName == _scheduleDialogSession).ToList(); + } + @if (existing.Any()) + { +
+
Active schedules for @_scheduleDialogSession
+ @foreach (var sp in existing) + { + var spId = sp.Id; +
+ @sp.DisplayName + @FormatCardNextRun(sp) + +
+ } +
+ } + + + } + @if (_showFontBubble) {
@(fontSize)px
@@ -379,6 +458,15 @@ private string? initError; private bool _initializationComplete = false; private readonly Dictionary _fiestaStreamingMessages = new(StringComparer.Ordinal); + + // Scheduled prompt dialog state (card-triggered) + private string? _scheduleDialogSession; + private string _scheduleLabel = ""; + private string _schedulePrompt = ""; + private string _scheduleType = "delay"; + private int _scheduleDelayMinutes = 5; + private int _scheduleRepeatMinutes = 60; + private string? _scheduleStatus; private Dictionary _groupPhases = new(); protected override async Task OnInitializedAsync() @@ -2354,6 +2442,50 @@ } } + private void OpenSchedulePanel(string sessionName) + { + _scheduleDialogSession = sessionName; + _scheduleLabel = ""; + _schedulePrompt = ""; + _scheduleType = "delay"; + _scheduleDelayMinutes = 5; + _scheduleStatus = null; + } + + private void CloseSchedulePanel() + { + _scheduleDialogSession = null; + _scheduleStatus = null; + } + + private void ConfirmSchedule() + { + if (string.IsNullOrWhiteSpace(_schedulePrompt)) + { + _scheduleStatus = "⚠ Enter a prompt."; + return; + } + var runAt = _scheduleType == "delay" + ? DateTime.UtcNow.AddMinutes(_scheduleDelayMinutes) + : DateTime.UtcNow.AddMinutes(_scheduleRepeatMinutes); + var repeat = _scheduleType == "repeat" ? _scheduleRepeatMinutes : 0; + ScheduledPrompts.Add(_scheduleDialogSession!, _schedulePrompt, _scheduleLabel, runAt, repeat); + _scheduleStatus = "βœ“ Scheduled!"; + _schedulePrompt = ""; + _scheduleLabel = ""; + } + + private static string FormatCardNextRun(ScheduledPrompt sp) + { + if (!sp.IsEnabled || sp.NextRunAt == null) return "disabled"; + var diff = sp.NextRunAt.Value - DateTime.UtcNow; + if (diff.TotalSeconds < 0) return "due now"; + if (diff.TotalMinutes < 1) return "< 1 min"; + if (diff.TotalHours < 1) return $"{(int)diff.TotalMinutes}m"; + if (diff.TotalDays < 1) return $"{(int)diff.TotalHours}h {diff.Minutes}m"; + return $"{(int)diff.TotalDays}d"; + } + private async Task HandleCardRenameKeyDown(KeyboardEventArgs e) { if (e.Key == "Enter") await CommitCardRename(); diff --git a/PolyPilot/Components/Pages/Dashboard.razor.css b/PolyPilot/Components/Pages/Dashboard.razor.css index 5eba4849..26158afa 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor.css +++ b/PolyPilot/Components/Pages/Dashboard.razor.css @@ -1444,3 +1444,138 @@ .ma-expanded-toolbar-input::placeholder { color: var(--text-muted); } + +/* Schedule prompt dialog (card-triggered overlay) */ +.schedule-dialog-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.45); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} +.schedule-dialog { + background: var(--surface-primary); + border: 1px solid var(--control-border); + border-radius: 10px; + padding: 16px; + width: 340px; + max-width: 90vw; + display: flex; + flex-direction: column; + gap: 8px; + box-shadow: 0 8px 32px rgba(0,0,0,0.4); +} +.schedule-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + font-weight: 600; + font-size: 0.9rem; +} +.schedule-dialog-label { + font-size: 0.78rem; + color: var(--text-secondary); +} +.schedule-dialog-input { + background: var(--surface-secondary); + border: 1px solid var(--control-border); + border-radius: 5px; + color: var(--text-primary); + font-size: 0.82rem; + padding: 6px 8px; + width: 100%; + box-sizing: border-box; +} +.schedule-dialog-textarea { + background: var(--surface-secondary); + border: 1px solid var(--control-border); + border-radius: 5px; + color: var(--text-primary); + font-size: 0.82rem; + padding: 6px 8px; + width: 100%; + box-sizing: border-box; + resize: vertical; + font-family: inherit; +} +.schedule-dialog-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.82rem; +} +.schedule-dialog-row label { color: var(--text-secondary); white-space: nowrap; } +.schedule-dialog-row select { + flex: 1; + background: var(--surface-secondary); + border: 1px solid var(--control-border); + border-radius: 5px; + color: var(--text-primary); + font-size: 0.82rem; + padding: 4px 6px; +} +.schedule-dialog-status { + font-size: 0.78rem; + padding: 4px 6px; + border-radius: 4px; +} +.schedule-dialog-status.success { color: #3fb950; } +.schedule-dialog-status.error { color: var(--accent-primary); } +.schedule-dialog-actions { + display: flex; + justify-content: flex-end; +} +.schedule-dialog-btn { + background: var(--accent-secondary, #58a6ff); + color: #fff; + border: none; + border-radius: 5px; + padding: 6px 16px; + cursor: pointer; + font-size: 0.85rem; + font-weight: 600; +} +.schedule-dialog-btn:hover { opacity: 0.85; } +.schedule-dialog-list { + border-top: 1px solid var(--control-border); + padding-top: 8px; + display: flex; + flex-direction: column; + gap: 4px; +} +.schedule-dialog-list-title { + font-size: 0.72rem; + color: var(--text-secondary); + margin-bottom: 2px; +} +.schedule-dialog-list-item { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.78rem; +} +.schedule-dialog-list-item.disabled { opacity: 0.45; } +.sdi-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-primary); +} +.sdi-time { + font-size: 0.7rem; + color: var(--accent-secondary, #58a6ff); + white-space: nowrap; +} +.sdi-delete { + background: none; + border: none; + cursor: pointer; + color: var(--text-secondary); + font-size: 0.75rem; + padding: 0 2px; + opacity: 0.6; +} +.sdi-delete:hover { color: var(--accent-primary); opacity: 1; } diff --git a/PolyPilot/Components/SessionCard.razor b/PolyPilot/Components/SessionCard.razor index d30118cd..3ced5e06 100644 --- a/PolyPilot/Components/SessionCard.razor +++ b/PolyPilot/Components/SessionCard.razor @@ -65,6 +65,9 @@ πŸ”” Notify when done } +