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.Tests/PolyPilot.Tests.csproj b/PolyPilot.Tests/PolyPilot.Tests.csproj index 50c94a1f..f6dd1852 100644 --- a/PolyPilot.Tests/PolyPilot.Tests.csproj +++ b/PolyPilot.Tests/PolyPilot.Tests.csproj @@ -71,6 +71,8 @@ + + diff --git a/PolyPilot.Tests/ScheduledPromptServiceTests.cs b/PolyPilot.Tests/ScheduledPromptServiceTests.cs new file mode 100644 index 00000000..76e1ada0 --- /dev/null +++ b/PolyPilot.Tests/ScheduledPromptServiceTests.cs @@ -0,0 +1,192 @@ +using PolyPilot.Models; +using PolyPilot.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace PolyPilot.Tests; + +public class ScheduledPromptServiceTests : IDisposable +{ + private readonly string _testDir; + private readonly string _storeFile; + + public ScheduledPromptServiceTests() + { + _testDir = Path.Combine(Path.GetTempPath(), $"polypilot-sched-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_testDir); + _storeFile = Path.Combine(_testDir, "scheduled-prompts.json"); + ScheduledPromptService.SetStorePathForTesting(_storeFile); + } + + public void Dispose() + { + // Reset to an isolated non-shared path so parallel tests are unaffected + ScheduledPromptService.SetStorePathForTesting(Path.Combine(_testDir, "noop.json")); + try { Directory.Delete(_testDir, true); } catch { } + } + + private static CopilotService CreateCopilotService() + { + var sp = new ServiceCollection().BuildServiceProvider(); + return new CopilotService( + new StubChatDatabase(), + new StubServerManager(), + new StubWsBridgeClient(), + new RepoManager(), + sp, + new StubDemoService()); + } + + [Fact] + public void ScheduledPrompt_DefaultValues() + { + var p = new ScheduledPrompt(); + Assert.False(string.IsNullOrEmpty(p.Id)); + Assert.Equal("", p.Label); + Assert.Equal("", p.Prompt); + Assert.Equal("", p.SessionName); + Assert.Null(p.NextRunAt); + Assert.Equal(0, p.RepeatIntervalMinutes); + Assert.Null(p.LastRunAt); + Assert.True(p.IsEnabled); + } + + [Fact] + public void DisplayName_UsesLabel_WhenSet() + { + var p = new ScheduledPrompt { Label = "My check", Prompt = "How are things going?" }; + Assert.Equal("My check", p.DisplayName); + } + + [Fact] + public void DisplayName_TruncatesLongPrompt_WhenNoLabel() + { + var p = new ScheduledPrompt { Label = "", Prompt = "This is a very long prompt that exceeds forty characters in length" }; + Assert.EndsWith("…", p.DisplayName); + Assert.True(p.DisplayName.Length <= 40); + } + + [Fact] + public void DisplayName_ShortPrompt_UsedDirectly() + { + var p = new ScheduledPrompt { Label = "", Prompt = "Short prompt" }; + Assert.Equal("Short prompt", p.DisplayName); + } + + [Fact] + public void Add_AddsPromptAndPersists() + { + var svc = new ScheduledPromptService(CreateCopilotService()); + var p = svc.Add("session1", "Check status", "Status check", DateTime.UtcNow.AddMinutes(5)); + + Assert.Single(svc.Prompts); + Assert.Equal("session1", p.SessionName); + Assert.Equal("Check status", p.Prompt); + Assert.Equal("Status check", p.Label); + Assert.True(p.IsEnabled); + Assert.True(File.Exists(_storeFile)); + } + + [Fact] + public void Add_WithRepeat_SetsRepeatInterval() + { + var svc = new ScheduledPromptService(CreateCopilotService()); + var p = svc.Add("session1", "Daily check", repeatIntervalMinutes: 1440); + Assert.Equal(1440, p.RepeatIntervalMinutes); + } + + [Fact] + public void Remove_RemovesPromptAndPersists() + { + var svc = new ScheduledPromptService(CreateCopilotService()); + var p = svc.Add("session1", "Check status"); + Assert.True(svc.Remove(p.Id)); + Assert.Empty(svc.Prompts); + } + + [Fact] + public void Remove_ReturnsFalse_WhenNotFound() + { + var svc = new ScheduledPromptService(CreateCopilotService()); + Assert.False(svc.Remove("nonexistent-id")); + } + + [Fact] + public void SetEnabled_TogglesEnabledState() + { + var svc = new ScheduledPromptService(CreateCopilotService()); + var p = svc.Add("session1", "Check status"); + + svc.SetEnabled(p.Id, false); + Assert.False(svc.Prompts[0].IsEnabled); + + svc.SetEnabled(p.Id, true); + Assert.True(svc.Prompts[0].IsEnabled); + } + + [Fact] + public void Persistence_RoundTrip() + { + var svc = new ScheduledPromptService(CreateCopilotService()); + var p = svc.Add("session1", "Hello world", "Test", DateTime.UtcNow.AddMinutes(10), 60); + + var svc2 = new ScheduledPromptService(CreateCopilotService()); + Assert.Single(svc2.Prompts); + var loaded = svc2.Prompts[0]; + Assert.Equal(p.Id, loaded.Id); + Assert.Equal("session1", loaded.SessionName); + Assert.Equal("Hello world", loaded.Prompt); + Assert.Equal("Test", loaded.Label); + Assert.Equal(60, loaded.RepeatIntervalMinutes); + Assert.True(loaded.IsEnabled); + } + + [Fact] + public void FirePromptAsync_OneShot_DisablesAfterFiring_WhenSessionNotFound_Retries() + { + // When session doesn't exist, the prompt should stay enabled for retry + var svc = new ScheduledPromptService(CreateCopilotService()); + var p = svc.Add("nonexistent-session", "One-shot", runAt: DateTime.UtcNow, repeatIntervalMinutes: 0); + + svc.FirePromptAsync(p).GetAwaiter().GetResult(); + + // Session not found β†’ still enabled (will retry on next check) + Assert.True(p.IsEnabled); + Assert.NotNull(p.NextRunAt); + } + + [Fact] + public void FirePromptAsync_Repeat_WhenSessionNotFound_Retries() + { + // When session doesn't exist, keeps the original NextRunAt for retry + var svc = new ScheduledPromptService(CreateCopilotService()); + var runAt = DateTime.UtcNow; + var p = svc.Add("nonexistent-session", "Repeating", runAt: runAt, repeatIntervalMinutes: 30); + + svc.FirePromptAsync(p).GetAwaiter().GetResult(); + + // Session not found β†’ stays enabled, NextRunAt unchanged + Assert.True(p.IsEnabled); + Assert.Equal(runAt, p.NextRunAt); + } + + [Fact] + public void OnStateChanged_FiresOnAdd() + { + var svc = new ScheduledPromptService(CreateCopilotService()); + var fired = false; + svc.OnStateChanged += () => fired = true; + svc.Add("s", "p"); + Assert.True(fired); + } + + [Fact] + public void OnStateChanged_FiresOnRemove() + { + var svc = new ScheduledPromptService(CreateCopilotService()); + var p = svc.Add("s", "p"); + var fired = false; + svc.OnStateChanged += () => fired = true; + svc.Remove(p.Id); + Assert.True(fired); + } +} diff --git a/PolyPilot/App.xaml.cs b/PolyPilot/App.xaml.cs index 7a554b3f..1e0f8247 100644 --- a/PolyPilot/App.xaml.cs +++ b/PolyPilot/App.xaml.cs @@ -4,10 +4,11 @@ namespace PolyPilot; public partial class App : Application { - public App(INotificationManagerService notificationService) + public App(INotificationManagerService notificationService, ScheduledPromptService scheduledPrompts) { InitializeComponent(); _ = notificationService.InitializeAsync(); + scheduledPrompts.Start(); } protected override Window CreateWindow(IActivationState? activationState) diff --git a/PolyPilot/Components/Layout/SessionListItem.razor b/PolyPilot/Components/Layout/SessionListItem.razor index 46bbeccb..8132ead4 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,21 @@ πŸ“Œ Pin } + @if (Session.NotifyOnComplete) + { + + } + else + { + + } + @if (Groups != null && Groups.Count > 1) { @@ -228,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/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..3ced5e06 100644 --- a/PolyPilot/Components/SessionCard.razor +++ b/PolyPilot/Components/SessionCard.razor @@ -10,6 +10,10 @@ { πŸ“Œ } + @if (Session.NotifyOnComplete) + { + πŸ”” + } @if (IsRenaming) { @@ -49,6 +53,21 @@ πŸ“Œ Pin } + @if (Session.NotifyOnComplete) + { + + } + else + { + + } +
public bool IsHidden { get; set; } + + /// + /// When true, a system notification is sent when this session completes, + /// regardless of the global EnableSessionNotifications setting. + /// + public bool NotifyOnComplete { get; set; } } diff --git a/PolyPilot/Models/ConnectionSettings.cs b/PolyPilot/Models/ConnectionSettings.cs index 4dfda1cb..8bde1e27 100644 --- a/PolyPilot/Models/ConnectionSettings.cs +++ b/PolyPilot/Models/ConnectionSettings.cs @@ -62,6 +62,12 @@ public class ConnectionSettings public List DisabledPlugins { get; set; } = new(); public bool EnableSessionNotifications { get; set; } = false; + /// + /// When non-zero, sends a "still running" reminder notification every N minutes + /// while a session is processing. 0 = disabled. + /// + public int NotificationReminderIntervalMinutes { get; set; } = 0; + /// /// Normalizes a remote URL by ensuring it has an http(s):// scheme. /// Plain IPs/hostnames get http://, devtunnels/known TLS hosts get https://. diff --git a/PolyPilot/Models/ScheduledPrompt.cs b/PolyPilot/Models/ScheduledPrompt.cs new file mode 100644 index 00000000..41362b51 --- /dev/null +++ b/PolyPilot/Models/ScheduledPrompt.cs @@ -0,0 +1,53 @@ +using System.Text.Json.Serialization; + +namespace PolyPilot.Models; + +public class ScheduledPrompt +{ + public string Id { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Optional display label shown in the UI. If empty, the prompt text is used. + /// + public string Label { get; set; } = ""; + + /// + /// The prompt text to send to the session when the timer fires. + /// + public string Prompt { get; set; } = ""; + + /// + /// Name of the session to send the prompt to. + /// + public string SessionName { get; set; } = ""; + + /// + /// When this scheduled prompt should next run (UTC). + /// Null means it has no pending run (one-shot that has already fired, or disabled). + /// + public DateTime? NextRunAt { get; set; } + + /// + /// If > 0, the prompt repeats every N minutes after each run. + /// If 0, the prompt fires once and is then disabled. + /// + public int RepeatIntervalMinutes { get; set; } = 0; + + /// + /// When this prompt last ran (UTC). + /// + public DateTime? LastRunAt { get; set; } + + /// + /// Whether this scheduled prompt is active. + /// + public bool IsEnabled { get; set; } = true; + + /// + /// Display name: label if set, otherwise truncated prompt. + /// + [JsonIgnore] + public string DisplayName => !string.IsNullOrWhiteSpace(Label) + ? Label + : Prompt.Length > 40 ? Prompt[..37] + "…" : Prompt; +} diff --git a/PolyPilot/Services/CopilotService.Events.cs b/PolyPilot/Services/CopilotService.Events.cs index c108f714..d3b41db0 100644 --- a/PolyPilot/Services/CopilotService.Events.cs +++ b/PolyPilot/Services/CopilotService.Events.cs @@ -464,7 +464,9 @@ void Invoke(Action action) try { var currentSettings = ConnectionSettings.Load(); - if (!currentSettings.EnableSessionNotifications) return; + var notifyGlobal = currentSettings.EnableSessionNotifications; + var notifySession = state.Info.NotifyOnComplete; + if (!notifyGlobal && !notifySession) return; var notifService = _serviceProvider?.GetService(); if (notifService == null || !notifService.HasPermission) return; var lastMsg = state.Info.History.LastOrDefault(m => m.Role == "assistant"); @@ -1366,6 +1368,11 @@ private async Task RunProcessingWatchdogAsync(SessionState state, string session : 0; var exceededMaxTime = totalProcessingSeconds >= WatchdogMaxProcessingTimeSeconds; + // Send periodic "still running" reminder if configured β€” load settings once per check + // (not inside the helper) to avoid redundant disk reads per watchdog iteration. + var watchdogSettings = ConnectionSettings.Load(); + _ = SendReminderNotificationIfDueAsync(state, sessionName, totalProcessingSeconds, watchdogSettings.NotificationReminderIntervalMinutes); + if (elapsed >= effectiveTimeout || exceededMaxTime) { var timeoutDisplay = exceededMaxTime @@ -1421,4 +1428,39 @@ private async Task RunProcessingWatchdogAsync(SessionState state, string session catch (OperationCanceledException) { /* Normal cancellation when response completes */ } catch (Exception ex) { Debug($"Watchdog error for '{sessionName}': {ex.Message}"); } } + + /// + /// Fires a "still running" reminder notification if the configured interval has elapsed + /// since the last reminder. Safe to call from the watchdog background thread. + /// + private async Task SendReminderNotificationIfDueAsync(SessionState state, string sessionName, double totalProcessingSeconds, int intervalMinutes) + { + try + { + if (intervalMinutes <= 0) return; + var notifService = _serviceProvider?.GetService(); + if (notifService == null || !notifService.HasPermission) return; + + var elapsedMinutes = (int)(totalProcessingSeconds / 60); + if (elapsedMinutes < intervalMinutes) return; + + // Compute how many complete intervals have elapsed + var intervalsDone = elapsedMinutes / intervalMinutes; + var lastSent = Volatile.Read(ref state.LastReminderSentAtMinutes); + // Only send once per interval window + if (intervalsDone <= lastSent) return; + + // Atomically claim this interval so concurrent checks don't double-fire + if (Interlocked.CompareExchange(ref state.LastReminderSentAtMinutes, intervalsDone, lastSent) != lastSent) return; + + var elapsed = elapsedMinutes >= 60 + ? $"{elapsedMinutes / 60}h {elapsedMinutes % 60}m" + : $"{elapsedMinutes}m"; + await notifService.SendNotificationAsync( + sessionName, + $"⏱ Still running Β· {elapsed} elapsed", + state.Info.SessionId); + } + catch (Exception ex) { Debug($"Reminder notification failed for '{sessionName}': {ex.Message}"); } + } } diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 6956d526..bc0d265c 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -269,6 +269,11 @@ private class SessionState /// public int SendingFlag; /// + /// Tracks when the last "still running" reminder notification was sent (in minutes elapsed). + /// Used by the watchdog to avoid sending duplicate reminders in the same interval window. + /// + public int LastReminderSentAtMinutes; + /// /// Tracks reasoning messages that have been created but not yet added to History /// (pending InvokeOnUI). Prevents duplicate creation when rapid deltas arrive /// for the same reasoningId before the UI thread posts the History.Add. @@ -1823,6 +1828,7 @@ public async Task SendPromptAsync(string sessionName, string prompt, Lis Interlocked.Exchange(ref state.ActiveToolCallCount, 0); // Reset stale tool count from previous turn state.HasUsedToolsThisTurn = false; // Reset stale tool flag from previous turn state.IsMultiAgentSession = IsSessionInMultiAgentGroup(sessionName); // Cache for watchdog (UI thread safe) + Interlocked.Exchange(ref state.LastReminderSentAtMinutes, 0); // Reset reminder timer for new turn Debug($"[SEND] '{sessionName}' IsProcessing=true gen={Interlocked.Read(ref state.ProcessingGeneration)} (thread={Environment.CurrentManagedThreadId})"); state.ResponseCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); state.CurrentResponse.Clear(); @@ -2290,6 +2296,17 @@ public bool SwitchSession(string name) return true; } + /// + /// Sets per-session notification preference. + /// When true, a system notification is sent when this session completes, + /// regardless of the global EnableSessionNotifications setting. + /// + public void SetSessionNotifyOnComplete(string sessionName, bool notify) + { + if (_sessions.TryGetValue(sessionName, out var state)) + state.Info.NotifyOnComplete = notify; + } + public bool RenameSession(string oldName, string newName) { if (string.IsNullOrWhiteSpace(newName)) diff --git a/PolyPilot/Services/ScheduledPromptService.cs b/PolyPilot/Services/ScheduledPromptService.cs new file mode 100644 index 00000000..89f076c9 --- /dev/null +++ b/PolyPilot/Services/ScheduledPromptService.cs @@ -0,0 +1,247 @@ +using System.Text.Json; +using PolyPilot.Models; + +namespace PolyPilot.Services; + +/// +/// Manages scheduled prompts: persists them, runs a background timer that fires +/// due prompts into their target Copilot sessions, and notifies when results arrive. +/// +public class ScheduledPromptService : IDisposable +{ + private static readonly object _pathLock = new(); + private static string? _storePath; + private static string StorePath + { + get + { + lock (_pathLock) + { + if (_storePath == null) + _storePath = Path.Combine(GetPolyPilotDir(), "scheduled-prompts.json"); + return _storePath; + } + } + } + + /// Override the store path for tests. + internal static void SetStorePathForTesting(string path) { lock (_pathLock) _storePath = path; } + + private static string GetPolyPilotDir() + { +#if IOS || ANDROID + try { return Path.Combine(FileSystem.AppDataDirectory, ".polypilot"); } + catch + { + var fallback = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrEmpty(fallback)) fallback = Path.GetTempPath(); + return Path.Combine(fallback, ".polypilot"); + } +#else + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(home)) + home = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Combine(home, ".polypilot"); +#endif + } + + private readonly CopilotService _copilot; + private readonly INotificationManagerService? _notifications; + private readonly List _prompts = new(); + private readonly object _lock = new(); + private Timer? _timer; + private int _firing; // Interlocked flag to prevent concurrent firings + + public event Action? OnStateChanged; + + public ScheduledPromptService(CopilotService copilot, INotificationManagerService? notifications = null) + { + _copilot = copilot; + _notifications = notifications; + Load(); + } + + /// Snapshot of all scheduled prompts (safe to iterate on the UI thread). + public IReadOnlyList Prompts + { + get { lock (_lock) return _prompts.ToList(); } + } + + /// Starts the background timer that checks for due prompts every minute. + public void Start() + { + _timer?.Dispose(); + _timer = new Timer(CheckDuePrompts, null, TimeSpan.FromSeconds(15), TimeSpan.FromMinutes(1)); + } + + /// + /// Adds a new scheduled prompt and saves. + /// If is null, schedules for immediate execution. + /// + public ScheduledPrompt Add(string sessionName, string prompt, string label = "", + DateTime? runAt = null, int repeatIntervalMinutes = 0) + { + var sp = new ScheduledPrompt + { + SessionName = sessionName, + Prompt = prompt, + Label = label, + NextRunAt = runAt ?? DateTime.UtcNow, + RepeatIntervalMinutes = repeatIntervalMinutes, + IsEnabled = true + }; + lock (_lock) _prompts.Add(sp); + Save(); + NotifyChanged(); + return sp; + } + + /// Removes a scheduled prompt by ID and saves. + public bool Remove(string id) + { + bool removed; + lock (_lock) removed = _prompts.RemoveAll(p => p.Id == id) > 0; + if (!removed) return false; + Save(); + NotifyChanged(); + return true; + } + + /// Enables or disables a scheduled prompt by ID and saves. + public bool SetEnabled(string id, bool enabled) + { + ScheduledPrompt? target; + lock (_lock) target = _prompts.FirstOrDefault(p => p.Id == id); + if (target == null) return false; + target.IsEnabled = enabled; + Save(); + NotifyChanged(); + return true; + } + + // ---- Background execution ---- + + private void CheckDuePrompts(object? state) + { + if (Interlocked.CompareExchange(ref _firing, 1, 0) != 0) return; + _ = CheckDuePromptsAsync(); + } + + private async Task CheckDuePromptsAsync() + { + try + { + List due; + lock (_lock) + due = _prompts + .Where(p => p.IsEnabled && p.NextRunAt.HasValue && p.NextRunAt.Value <= DateTime.UtcNow) + .ToList(); + + foreach (var sp in due) + await FirePromptAsync(sp); + } + catch (Exception ex) + { + Console.WriteLine($"[ScheduledPrompt] Background check error: {ex.Message}"); + } + finally + { + Interlocked.Exchange(ref _firing, 0); + } + } + + internal async Task FirePromptAsync(ScheduledPrompt sp) + { + try + { + sp.LastRunAt = DateTime.UtcNow; + + // Send the prompt to the target session + var session = _copilot.GetSession(sp.SessionName); + if (session == null) + { + // Session gone β€” keep enabled so it retries on next check + Console.WriteLine($"[ScheduledPrompt] Session '{sp.SessionName}' not found; will retry."); + return; + } + + // Advance or disable now that we've confirmed the session exists + if (sp.RepeatIntervalMinutes > 0) + sp.NextRunAt = DateTime.UtcNow.AddMinutes(sp.RepeatIntervalMinutes); + else + { + sp.NextRunAt = null; + sp.IsEnabled = false; + } + + Save(); + NotifyChanged(); + + if (session.IsProcessing) + { + // Session busy β€” queue for when it's ready + _copilot.EnqueueMessage(sp.SessionName, sp.Prompt); + return; + } + + var result = await _copilot.SendPromptAsync(sp.SessionName, sp.Prompt); + + // Notify user the result is ready + if (_notifications != null && _notifications.HasPermission) + { + var bodyPreview = result.Length > 80 ? result[..77] + "…" : result; + var cleanBody = bodyPreview.Replace("\n", " ").Replace("\r", "").Trim(); + await _notifications.SendNotificationAsync( + $"πŸ“… {sp.DisplayName}", + string.IsNullOrWhiteSpace(cleanBody) ? "Scheduled prompt complete" : cleanBody, + session.SessionId); + } + } + catch (Exception ex) + { + Console.WriteLine($"[ScheduledPrompt] Error firing '{sp.DisplayName}': {ex.Message}"); + } + } + + // ---- Persistence ---- + + private void Load() + { + try + { + if (!File.Exists(StorePath)) return; + var json = File.ReadAllText(StorePath); + var loaded = JsonSerializer.Deserialize>(json); + if (loaded == null) return; + lock (_lock) + { + _prompts.Clear(); + _prompts.AddRange(loaded); + } + } + catch { } + } + + internal void Save() + { + try + { + List snapshot; + lock (_lock) snapshot = _prompts.ToList(); + var dir = Path.GetDirectoryName(StorePath)!; + Directory.CreateDirectory(dir); + var json = JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(StorePath, json); + } + catch { } + } + + private void NotifyChanged() => OnStateChanged?.Invoke(); + + public void Dispose() + { + _timer?.Dispose(); + _timer = null; + GC.SuppressFinalize(this); + } +}