Skip to content
This repository was archived by the owner on May 24, 2026. It is now read-only.

Commit c945076

Browse files
CopilotPureWeen
andcommitted
Add per-session notifications and timer-based reminders
Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com>
1 parent 5114e08 commit c945076

9 files changed

Lines changed: 190 additions & 1 deletion

File tree

PolyPilot.Tests/AgentSessionInfoTests.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,4 +167,22 @@ public void UnreadCount_HandlesLastReadBeyondHistory()
167167

168168
Assert.Equal(0, session.UnreadCount);
169169
}
170+
171+
[Fact]
172+
public void NotifyOnComplete_DefaultsFalse()
173+
{
174+
var session = new AgentSessionInfo { Name = "test", Model = "gpt-5" };
175+
Assert.False(session.NotifyOnComplete);
176+
}
177+
178+
[Fact]
179+
public void NotifyOnComplete_CanBeSetAndCleared()
180+
{
181+
var session = new AgentSessionInfo { Name = "test", Model = "gpt-5" };
182+
session.NotifyOnComplete = true;
183+
Assert.True(session.NotifyOnComplete);
184+
185+
session.NotifyOnComplete = false;
186+
Assert.False(session.NotifyOnComplete);
187+
}
170188
}

PolyPilot.Tests/ConnectionSettingsTests.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,48 @@ public void NormalizeRemoteUrl_DoesNotDoubleScheme()
391391
Assert.Equal("http://http://example.com", result);
392392
}
393393

394+
[Fact]
395+
public void EnableSessionNotifications_DefaultsFalse()
396+
{
397+
var settings = new ConnectionSettings();
398+
Assert.False(settings.EnableSessionNotifications);
399+
}
400+
401+
[Fact]
402+
public void NotificationReminderIntervalMinutes_DefaultsZero()
403+
{
404+
var settings = new ConnectionSettings();
405+
Assert.Equal(0, settings.NotificationReminderIntervalMinutes);
406+
}
407+
408+
[Fact]
409+
public void NotificationReminderIntervalMinutes_CanBeSet()
410+
{
411+
var settings = new ConnectionSettings { NotificationReminderIntervalMinutes = 5 };
412+
Assert.Equal(5, settings.NotificationReminderIntervalMinutes);
413+
}
414+
415+
[Fact]
416+
public void NotificationReminderIntervalMinutes_RoundTripsViaSerialization()
417+
{
418+
var original = new ConnectionSettings { NotificationReminderIntervalMinutes = 10 };
419+
var json = JsonSerializer.Serialize(original);
420+
var loaded = JsonSerializer.Deserialize<ConnectionSettings>(json);
421+
422+
Assert.NotNull(loaded);
423+
Assert.Equal(10, loaded!.NotificationReminderIntervalMinutes);
424+
}
425+
426+
[Fact]
427+
public void NotificationReminderIntervalMinutes_BackwardCompatibility_DefaultsZero()
428+
{
429+
var json = """{"Mode":0,"Host":"localhost","Port":4321}""";
430+
var loaded = JsonSerializer.Deserialize<ConnectionSettings>(json);
431+
432+
Assert.NotNull(loaded);
433+
Assert.Equal(0, loaded!.NotificationReminderIntervalMinutes);
434+
}
435+
394436
private void Dispose()
395437
{
396438
try { Directory.Delete(_testDir, true); } catch { }

PolyPilot/Components/Layout/SessionListItem.razor

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@
3333
{
3434
<span class="pin-badge" title="Pinned" @onclick="() => OnPin.InvokeAsync(false)" @onclick:stopPropagation="true">📌</span>
3535
}
36+
@if (Session.NotifyOnComplete)
37+
{
38+
<span class="pin-badge" title="Notify when done" @onclick="() => { CopilotService.SetSessionNotifyOnComplete(Session.Name, false); }" @onclick:stopPropagation="true">🔔</span>
39+
}
3640
@if (IsCompleted)
3741
{
3842
<span class="done-badge">✅</span>
@@ -113,6 +117,18 @@
113117
📌 Pin
114118
</button>
115119
}
120+
@if (Session.NotifyOnComplete)
121+
{
122+
<button class="menu-item" @onclick="() => { OnCloseMenu.InvokeAsync(); CopilotService.SetSessionNotifyOnComplete(Session.Name, false); }">
123+
🔔 Watching (tap to stop)
124+
</button>
125+
}
126+
else
127+
{
128+
<button class="menu-item" @onclick="() => { OnCloseMenu.InvokeAsync(); CopilotService.SetSessionNotifyOnComplete(Session.Name, true); }">
129+
🔔 Notify when done
130+
</button>
131+
}
116132
@if (Groups != null && Groups.Count > 1)
117133
{
118134
<div class="menu-separator"></div>

PolyPilot/Components/Pages/Settings.razor

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,23 @@
515515
</label>
516516
<p class="toggle-hint">Get a system notification when an agent finishes responding</p>
517517
</div>
518+
@if (settings.EnableSessionNotifications)
519+
{
520+
<div class="notifications-toggle" style="margin-top: 8px;">
521+
<label class="toggle-label" style="align-items: center;">
522+
<span class="toggle-text">⏱ Reminder interval</span>
523+
<select class="form-input" style="margin-left: 8px; width: auto;" value="@settings.NotificationReminderIntervalMinutes" @onchange="OnReminderIntervalChanged">
524+
<option value="0">Off</option>
525+
<option value="2">Every 2 min</option>
526+
<option value="5">Every 5 min</option>
527+
<option value="10">Every 10 min</option>
528+
<option value="15">Every 15 min</option>
529+
<option value="30">Every 30 min</option>
530+
</select>
531+
</label>
532+
<p class="toggle-hint">Send a "still running" reminder while a session is processing</p>
533+
</div>
534+
}
518535
</div>
519536
</div>
520537

@@ -1017,6 +1034,15 @@
10171034
}
10181035
}
10191036

1037+
private void OnReminderIntervalChanged(ChangeEventArgs e)
1038+
{
1039+
if (int.TryParse(e.Value?.ToString(), out var minutes))
1040+
{
1041+
settings.NotificationReminderIntervalMinutes = minutes;
1042+
settings.Save();
1043+
}
1044+
}
1045+
10201046
private void ToggleAutoUpdate()
10211047
{
10221048
if (GitAutoUpdate.IsEnabled)

PolyPilot/Components/SessionCard.razor

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
{
1111
<span class="card-pin-indicator">📌</span>
1212
}
13+
@if (Session.NotifyOnComplete)
14+
{
15+
<span class="card-pin-indicator" title="Notify when done">🔔</span>
16+
}
1317
<span class="card-status-dot @(Session.IsProcessing ? "processing" : IsCompleted ? "completed" : "idle")"></span>
1418
@if (IsRenaming)
1519
{
@@ -49,6 +53,18 @@
4953
📌 Pin
5054
</button>
5155
}
56+
@if (Session.NotifyOnComplete)
57+
{
58+
<button class="card-menu-item" @onclick="() => { OnCloseMenu.InvokeAsync(); CopilotService.SetSessionNotifyOnComplete(Session.Name, false); }">
59+
🔔 Watching (tap to stop)
60+
</button>
61+
}
62+
else
63+
{
64+
<button class="card-menu-item" @onclick="() => { OnCloseMenu.InvokeAsync(); CopilotService.SetSessionNotifyOnComplete(Session.Name, true); }">
65+
🔔 Notify when done
66+
</button>
67+
}
5268
<div class="card-menu-separator"></div>
5369
<button class="card-menu-item destructive" @onclick="async () => { await OnCloseMenu.InvokeAsync(); _ = CopilotService.CloseSessionAsync(Session.Name); }">
5470
Close Session

PolyPilot/Models/AgentSessionInfo.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,10 @@ public int UnreadCount
8282
/// Hidden sessions are not shown in the sidebar (e.g., evaluator sessions).
8383
/// </summary>
8484
public bool IsHidden { get; set; }
85+
86+
/// <summary>
87+
/// When true, a system notification is sent when this session completes,
88+
/// regardless of the global EnableSessionNotifications setting.
89+
/// </summary>
90+
public bool NotifyOnComplete { get; set; }
8591
}

PolyPilot/Models/ConnectionSettings.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ public class ConnectionSettings
6262
public List<string> DisabledPlugins { get; set; } = new();
6363
public bool EnableSessionNotifications { get; set; } = false;
6464

65+
/// <summary>
66+
/// When non-zero, sends a "still running" reminder notification every N minutes
67+
/// while a session is processing. 0 = disabled.
68+
/// </summary>
69+
public int NotificationReminderIntervalMinutes { get; set; } = 0;
70+
6571
/// <summary>
6672
/// Normalizes a remote URL by ensuring it has an http(s):// scheme.
6773
/// Plain IPs/hostnames get http://, devtunnels/known TLS hosts get https://.

PolyPilot/Services/CopilotService.Events.cs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,9 @@ void Invoke(Action action)
464464
try
465465
{
466466
var currentSettings = ConnectionSettings.Load();
467-
if (!currentSettings.EnableSessionNotifications) return;
467+
var notifyGlobal = currentSettings.EnableSessionNotifications;
468+
var notifySession = state.Info.NotifyOnComplete;
469+
if (!notifyGlobal && !notifySession) return;
468470
var notifService = _serviceProvider?.GetService<INotificationManagerService>();
469471
if (notifService == null || !notifService.HasPermission) return;
470472
var lastMsg = state.Info.History.LastOrDefault(m => m.Role == "assistant");
@@ -1366,6 +1368,11 @@ private async Task RunProcessingWatchdogAsync(SessionState state, string session
13661368
: 0;
13671369
var exceededMaxTime = totalProcessingSeconds >= WatchdogMaxProcessingTimeSeconds;
13681370

1371+
// Send periodic "still running" reminder if configured — load settings once per check
1372+
// (not inside the helper) to avoid redundant disk reads per watchdog iteration.
1373+
var watchdogSettings = ConnectionSettings.Load();
1374+
_ = SendReminderNotificationIfDueAsync(state, sessionName, totalProcessingSeconds, watchdogSettings.NotificationReminderIntervalMinutes);
1375+
13691376
if (elapsed >= effectiveTimeout || exceededMaxTime)
13701377
{
13711378
var timeoutDisplay = exceededMaxTime
@@ -1421,4 +1428,39 @@ private async Task RunProcessingWatchdogAsync(SessionState state, string session
14211428
catch (OperationCanceledException) { /* Normal cancellation when response completes */ }
14221429
catch (Exception ex) { Debug($"Watchdog error for '{sessionName}': {ex.Message}"); }
14231430
}
1431+
1432+
/// <summary>
1433+
/// Fires a "still running" reminder notification if the configured interval has elapsed
1434+
/// since the last reminder. Safe to call from the watchdog background thread.
1435+
/// </summary>
1436+
private async Task SendReminderNotificationIfDueAsync(SessionState state, string sessionName, double totalProcessingSeconds, int intervalMinutes)
1437+
{
1438+
try
1439+
{
1440+
if (intervalMinutes <= 0) return;
1441+
var notifService = _serviceProvider?.GetService<INotificationManagerService>();
1442+
if (notifService == null || !notifService.HasPermission) return;
1443+
1444+
var elapsedMinutes = (int)(totalProcessingSeconds / 60);
1445+
if (elapsedMinutes < intervalMinutes) return;
1446+
1447+
// Compute how many complete intervals have elapsed
1448+
var intervalsDone = elapsedMinutes / intervalMinutes;
1449+
var lastSent = Volatile.Read(ref state.LastReminderSentAtMinutes);
1450+
// Only send once per interval window
1451+
if (intervalsDone <= lastSent) return;
1452+
1453+
// Atomically claim this interval so concurrent checks don't double-fire
1454+
if (Interlocked.CompareExchange(ref state.LastReminderSentAtMinutes, intervalsDone, lastSent) != lastSent) return;
1455+
1456+
var elapsed = elapsedMinutes >= 60
1457+
? $"{elapsedMinutes / 60}h {elapsedMinutes % 60}m"
1458+
: $"{elapsedMinutes}m";
1459+
await notifService.SendNotificationAsync(
1460+
sessionName,
1461+
$"⏱ Still running · {elapsed} elapsed",
1462+
state.Info.SessionId);
1463+
}
1464+
catch (Exception ex) { Debug($"Reminder notification failed for '{sessionName}': {ex.Message}"); }
1465+
}
14241466
}

PolyPilot/Services/CopilotService.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,11 @@ private class SessionState
269269
/// </summary>
270270
public int SendingFlag;
271271
/// <summary>
272+
/// Tracks when the last "still running" reminder notification was sent (in minutes elapsed).
273+
/// Used by the watchdog to avoid sending duplicate reminders in the same interval window.
274+
/// </summary>
275+
public int LastReminderSentAtMinutes;
276+
/// <summary>
272277
/// Tracks reasoning messages that have been created but not yet added to History
273278
/// (pending InvokeOnUI). Prevents duplicate creation when rapid deltas arrive
274279
/// for the same reasoningId before the UI thread posts the History.Add.
@@ -1823,6 +1828,7 @@ public async Task<string> SendPromptAsync(string sessionName, string prompt, Lis
18231828
Interlocked.Exchange(ref state.ActiveToolCallCount, 0); // Reset stale tool count from previous turn
18241829
state.HasUsedToolsThisTurn = false; // Reset stale tool flag from previous turn
18251830
state.IsMultiAgentSession = IsSessionInMultiAgentGroup(sessionName); // Cache for watchdog (UI thread safe)
1831+
Interlocked.Exchange(ref state.LastReminderSentAtMinutes, 0); // Reset reminder timer for new turn
18261832
Debug($"[SEND] '{sessionName}' IsProcessing=true gen={Interlocked.Read(ref state.ProcessingGeneration)} (thread={Environment.CurrentManagedThreadId})");
18271833
state.ResponseCompletion = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
18281834
state.CurrentResponse.Clear();
@@ -2290,6 +2296,17 @@ public bool SwitchSession(string name)
22902296
return true;
22912297
}
22922298

2299+
/// <summary>
2300+
/// Sets per-session notification preference.
2301+
/// When true, a system notification is sent when this session completes,
2302+
/// regardless of the global EnableSessionNotifications setting.
2303+
/// </summary>
2304+
public void SetSessionNotifyOnComplete(string sessionName, bool notify)
2305+
{
2306+
if (_sessions.TryGetValue(sessionName, out var state))
2307+
state.Info.NotifyOnComplete = notify;
2308+
}
2309+
22932310
public bool RenameSession(string oldName, string newName)
22942311
{
22952312
if (string.IsNullOrWhiteSpace(newName))

0 commit comments

Comments
 (0)