Skip to content
Merged
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
7 changes: 5 additions & 2 deletions PolyPilot/App.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
namespace PolyPilot;
using PolyPilot.Services;

namespace PolyPilot;

public partial class App : Application
{
public App()
public App(INotificationManagerService notificationService)
{
InitializeComponent();
_ = notificationService.InitializeAsync();
}

protected override Window CreateWindow(IActivationState? activationState)
Expand Down
47 changes: 46 additions & 1 deletion PolyPilot/Components/ExpandedSessionView.razor
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@
<span class="status-sep">·</span>
<span class="skills-trigger" @onclick="ShowSkillsPopup">@availableSkills.Count skills</span>
}
@if (availableAgents?.Count > 0)
{
<span class="status-sep">·</span>
<span class="skills-trigger" @onclick="ShowAgentsPopup">@availableAgents.Count agents</span>
}
<span class="status-sep">·</span>
<select class="inline-model-select" value="@CurrentModel" @onchange="e => OnSetModel.InvokeAsync(e.Value?.ToString())" disabled="@Session.IsProcessing" title="@(Session.IsProcessing ? "Wait for response to complete" : "Switch model")">
@if (!AvailableModels.Contains(CurrentModel))
Expand All @@ -152,12 +157,12 @@
<option value="@m">@PrettifyModel(m)</option>
}
</select>
<div class="status-spacer"></div>
<span class="status-extra">
@if (UsageInfo != null)
{
@if (UsageInfo.InputTokens.HasValue || UsageInfo.OutputTokens.HasValue)
{
<span class="status-sep">·</span>
<span class="status-tokens">↑@FormatTokenCount(UsageInfo.InputTokens ?? 0) ↓@FormatTokenCount(UsageInfo.OutputTokens ?? 0)</span>
}
@if (UsageInfo.CurrentTokens.HasValue && UsageInfo.TokenLimit.HasValue)
Expand Down Expand Up @@ -258,6 +263,7 @@
name.Length <= maxLen ? name : name[..maxLen] + "…";

private List<SkillInfo>? availableSkills;
private List<AgentInfo>? availableAgents;
private string? _lastSkillSessionName;

protected override void OnParametersSet()
Expand All @@ -266,6 +272,7 @@
{
_lastSkillSessionName = Session.Name;
availableSkills = CopilotService.DiscoverAvailableSkills(Session.WorkingDirectory);
availableAgents = CopilotService.DiscoverAvailableAgents(Session.WorkingDirectory);
}
}

Expand Down Expand Up @@ -306,6 +313,44 @@
");
}

private async Task ShowAgentsPopup()
{
if (availableAgents == null || availableAgents.Count == 0) return;
var rows = string.Join("", availableAgents.Select(a =>
{
var desc = string.IsNullOrWhiteSpace(a.Description) ? "" :
$"<div style=\"font-size:13px;color:#a6adc8;margin-top:3px;line-height:1.4\">{EscapeHtml(TruncateDesc(a.Description))}</div>";
return $"<div style=\"padding:8px 14px;border-bottom:1px solid #313244\">" +
$"<div style=\"display:flex;justify-content:space-between;align-items:center;font-size:15px\">" +
$"<span style=\"color:#cdd6f4;font-weight:600\">{EscapeHtml(a.Name)}</span>" +
$"<span style=\"font-size:11px;color:#6c7086;background:rgba(255,255,255,0.05);padding:2px 8px;border-radius:3px;white-space:nowrap;margin-left:8px\">{EscapeHtml(a.Source)}</span></div>" +
desc + "</div>";
}));
var jsHtml = EscapeForJs(rows);
var headerHtml = EscapeForJs("<div style=\"padding:8px 14px;font-size:13px;color:#6c7086;border-bottom:1px solid #313244;font-weight:600\">Available Agents</div>");
await JS.InvokeVoidAsync("eval", $@"
(function(){{
var old = document.getElementById('skills-popup-overlay');
if(old) old.remove();
var trigger = document.querySelectorAll('[class*=""skills-trigger""]');
var el = trigger.length > 1 ? trigger[1] : trigger[0];
var rect = el ? el.getBoundingClientRect() : {{left:20,bottom:60}};
var ov = document.createElement('div');
ov.id = 'skills-popup-overlay';
ov.style.cssText = 'position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,0.3)';
ov.onclick = function(){{ ov.remove(); }};
var popup = document.createElement('div');
var left = Math.max(8, Math.min(rect.left, window.innerWidth - 368));
var bottom = window.innerHeight - rect.top + 8;
popup.style.cssText = 'position:fixed;bottom:'+bottom+'px;left:'+left+'px;z-index:9999;background:#1e1e2e;border:1px solid #45475a;border-radius:10px;padding:6px 0;min-width:240px;max-width:360px;max-height:50vh;overflow-y:auto;box-shadow:0 -4px 20px rgba(0,0,0,0.5)';
popup.innerHTML = '{headerHtml}{jsHtml}';
popup.onclick = function(e){{ e.stopPropagation(); }};
ov.appendChild(popup);
document.body.appendChild(ov);
}})()
");
}

private static string EscapeHtml(string s) =>
s.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;").Replace("\"", "&quot;");

Expand Down
1 change: 1 addition & 0 deletions PolyPilot/Components/ExpandedSessionView.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@
.inline-model-select:hover { color: var(--accent-primary); }
.inline-model-select option { background: var(--bg-tertiary); color: var(--text-primary); }
.status-extra { display: contents; }
.status-spacer { flex: 1; }

.font-size-controls { display: inline-flex; align-items: center; gap: 0.15rem; }
.font-size-btn { background: var(--hover-bg); border: 1px solid var(--hover-bg); border-radius: 4px; color: var(--text-secondary); cursor: pointer; font-size: 0.65rem; font-weight: 600; padding: 0.1rem 0.3rem; line-height: 1; }
Expand Down
4 changes: 4 additions & 0 deletions PolyPilot/Components/Layout/SessionListItem.razor
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@
else
{
<span class="session-name-text" @ondblclick="() => OnStartRename.InvokeAsync()" @ondblclick:stopPropagation="true">@Session.Name</span>
@if (Session.UnreadCount > 0)
{
<span class="unread-badge">@Session.UnreadCount</span>
}
}
</span>
<div class="session-meta-row">
Expand Down
17 changes: 17 additions & 0 deletions PolyPilot/Components/Layout/SessionListItem.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,20 @@
font-size: var(--type-callout);
}
}


.unread-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--accent-primary, #7c5cfc);
color: #fff;
font-size: 11px;
font-weight: 600;
margin-left: 6px;
flex-shrink: 0;
}
2 changes: 2 additions & 0 deletions PolyPilot/Components/Layout/SessionSidebar.razor
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,8 @@ else
{
completedSessions.Remove(name);
CopilotService.SwitchSession(name);
var session = CopilotService.GetSession(name);
if (session != null) session.LastReadMessageCount = session.History.Count;
CopilotService.SaveUiState("/", name);
await OnSessionSelected.InvokeAsync();
if (currentPage != "/")
Expand Down
7 changes: 7 additions & 0 deletions PolyPilot/Components/Pages/Dashboard.razor
Original file line number Diff line number Diff line change
Expand Up @@ -1553,6 +1553,9 @@
expandedSession = sessionName;
_needsScrollToBottom = true;
CopilotService.SwitchSession(sessionName);
// Mark messages as read
var session = CopilotService.GetSession(sessionName);
if (session != null) session.LastReadMessageCount = session.History.Count;
CopilotService.SaveUiState("/dashboard", activeSession: sessionName, expandedSession: sessionName, expandedGrid: !isCompactGrid);
}

Expand Down Expand Up @@ -1801,6 +1804,8 @@
await SaveDraftsAndCursor();
expandedSession = sessionName;
CopilotService.SwitchSession(sessionName);
var s = CopilotService.GetSession(sessionName);
if (s != null) s.LastReadMessageCount = s.History.Count;
StateHasChanged();
}

Expand All @@ -1826,6 +1831,8 @@
idx = reverse ? (idx - 1 + sessions.Count) % sessions.Count : (idx + 1) % sessions.Count;
expandedSession = sessions[idx].Name;
CopilotService.SwitchSession(expandedSession);
var cs = CopilotService.GetSession(expandedSession);
if (cs != null) cs.LastReadMessageCount = cs.History.Count;
_focusedInputId = $"input-{expandedSession.Replace(" ", "-")}";
_cursorStart = 0;
_cursorEnd = 0;
Expand Down
24 changes: 24 additions & 0 deletions PolyPilot/Components/Pages/Settings.razor
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
@inject GitAutoUpdateService GitAutoUpdate
@inject NavigationManager Nav
@inject IJSRuntime JS
@inject IServiceProvider ServiceProvider
@implements IDisposable

<div class="settings-page">
Expand Down Expand Up @@ -392,6 +393,17 @@
<button class="font-size-btn" @onclick="IncreaseFontSize" disabled="@(fontSize >= 24)">A+</button>
</div>
</div>

<div class="settings-section @(SectionVisible("notifications alert sound badge") ? "" : "search-hidden")">
<h3>Notifications</h3>
<div class="notifications-toggle">
<label class="toggle-label">
<input type="checkbox" checked="@settings.EnableSessionNotifications" @onchange="ToggleNotifications" />
<span class="toggle-text">🔔 Notify when sessions complete</span>
</label>
<p class="toggle-hint">Get a system notification when an agent finishes responding</p>
</div>
</div>
</div>

@if (GitAutoUpdate.IsAvailable)
Expand Down Expand Up @@ -778,6 +790,18 @@
CopilotService.SaveUiState("/settings", fontSize: fontSize);
}

private async Task ToggleNotifications(ChangeEventArgs e)
{
settings.EnableSessionNotifications = e.Value is true;
settings.Save();
if (settings.EnableSessionNotifications)
{
var notifService = ServiceProvider.GetService<INotificationManagerService>();
if (notifService != null && !notifService.HasPermission)
await notifService.InitializeAsync();
}
}

private void ToggleAutoUpdate()
{
if (GitAutoUpdate.IsEnabled)
Expand Down
4 changes: 4 additions & 0 deletions PolyPilot/Components/SessionCard.razor
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
else
{
<h3>@Session.Name</h3>
@if (Session.UnreadCount > 0)
{
<span class="unread-badge">@Session.UnreadCount</span>
}
}
</div>
<div class="card-more-wrapper">
Expand Down
17 changes: 17 additions & 0 deletions PolyPilot/Components/SessionCard.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -472,3 +472,20 @@
.card-input button:hover:not(:disabled) {
background: var(--hover-bg);
}


.unread-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--accent-primary, #7c5cfc);
color: #fff;
font-size: 11px;
font-weight: 600;
margin-left: 6px;
flex-shrink: 0;
}
9 changes: 9 additions & 0 deletions PolyPilot/Models/AgentSessionInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,13 @@ public class AgentSessionInfo
public int TotalOutputTokens { get; set; }
public int? ContextCurrentTokens { get; set; }
public int? ContextTokenLimit { get; set; }

/// <summary>
/// History.Count at the time the user last viewed this session.
/// Messages added after this count are "unread".
/// </summary>
public int LastReadMessageCount { get; set; }

public int UnreadCount => Math.Max(0,
History.Skip(LastReadMessageCount).Count(m => m.Role == "assistant"));
}
47 changes: 46 additions & 1 deletion PolyPilot/Services/CopilotService.Events.cs
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,24 @@ void Invoke(Action action)
CompleteResponse(state);
// Refresh git branch — agent may have switched branches
state.Info.GitBranch = GetGitBranch(state.Info.WorkingDirectory);
// Send notification when agent finishes
_ = Task.Run(async () =>
{
try
{
var currentSettings = ConnectionSettings.Load();
if (!currentSettings.EnableSessionNotifications) return;
var notifService = _serviceProvider?.GetService<INotificationManagerService>();
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);
await notifService.SendNotificationAsync(
$"✓ {sessionName}",
body,
state.Info.SessionId);
}
catch { }
});
break;

case SessionStartEvent start:
Expand Down Expand Up @@ -543,6 +561,9 @@ private void CompleteResponse(SessionState state)
var msg = new ChatMessage("assistant", response, DateTime.Now);
state.Info.History.Add(msg);
state.Info.MessageCount = state.Info.History.Count;
// If user is viewing this session, keep it read
if (state.Info.Name == _activeSessionName)
state.Info.LastReadMessageCount = state.Info.History.Count;

// Write-through to DB
if (!string.IsNullOrEmpty(state.Info.SessionId))
Expand All @@ -557,7 +578,6 @@ private void CompleteResponse(SessionState state)
// Fire completion notification
var summary = response.Length > 100 ? response[..100] + "..." : response;
OnSessionComplete?.Invoke(state.Info.Name, summary);
IncrementBadge();

// Auto-dispatch next queued message
if (state.Info.MessageQueue.Count > 0)
Expand All @@ -579,4 +599,29 @@ private void CompleteResponse(SessionState state)
});
}
}

private static string BuildNotificationBody(string? content, int messageCount)
{
if (string.IsNullOrWhiteSpace(content))
return $"Agent finished · {messageCount} messages";

// Strip markdown formatting for cleaner notification text
var text = content
.Replace("**", "").Replace("__", "")
.Replace("```", "").Replace("`", "")
.Replace("###", "").Replace("##", "").Replace("#", "")
.Replace("\r", "");

// Get first non-empty line as summary
var firstLine = text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault(l => l.Length > 5 && !l.StartsWith("---") && !l.StartsWith("- ["));

if (string.IsNullOrEmpty(firstLine))
return $"Agent finished · {messageCount} messages";

if (firstLine.Length > 120)
firstLine = firstLine[..117] + "…";

return firstLine;
}
}
Loading