Skip to content

Commit 145b82e

Browse files
feat: Add notification history panel with bell icon in footer (#585)
* Initial plan * feat: add notification history bell button in footer (#364) * Add Playwright e2e tests for notification history bell button * Fix: remove force:true from outside-click test, use activity-bar as click target * fix: notification panel hidden attribute overridden by CSS flex display * fix: move initNotificationHistoryPanel early and shrink BrowserView when panel is open * refactor: extract named constants for notification panel margin and min tool width * refactor: use BrowserWindow with alwaysOnTop for notification history, merge into notifications.ts * fix: extract named constants and clarify IPC args indexing in notification history manager * fix the alignment issue * Added proper copy and clear all functions --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Power-Maverick <danish.naglekar@hotmail.com>
1 parent 7ad9101 commit 145b82e

8 files changed

Lines changed: 716 additions & 6 deletions

File tree

src/main/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import { ConnectionsManager } from "./managers/connectionsManager";
3939
import { DataverseManager } from "./managers/dataverseManager";
4040
import { InstallIdManager } from "./managers/installIdManager";
4141
import { ModalWindowManager } from "./managers/modalWindowManager";
42-
import { NotificationWindowManager } from "./managers/notificationWindowManager";
42+
import { NotificationHistoryWindowManager, NotificationWindowManager } from "./managers/notificationWindowManager";
4343
import { PowerPlatformManager } from "./managers/powerplatformManager";
4444
import { ProtocolHandlerManager } from "./managers/protocolHandlerManager";
4545
import { SettingsManager } from "./managers/settingsManager";
@@ -79,6 +79,7 @@ class ToolBoxApp {
7979
private protocolHandlerManager: ProtocolHandlerManager;
8080
private toolWindowManager: ToolWindowManager | null = null;
8181
private notificationWindowManager: NotificationWindowManager | null = null;
82+
private notificationHistoryWindowManager: NotificationHistoryWindowManager | null = null;
8283
private modalWindowManager: ModalWindowManager | null = null;
8384
private trayManager: TrayManager | null = null;
8485
private api: ToolBoxUtilityManager;
@@ -2682,6 +2683,9 @@ class ToolBoxApp {
26822683

26832684
// Initialize NotificationWindowManager for overlay notifications
26842685
this.notificationWindowManager = new NotificationWindowManager(this.mainWindow);
2686+
// Initialize NotificationHistoryWindowManager for the bell-icon history panel
2687+
this.notificationHistoryWindowManager = new NotificationHistoryWindowManager(this.mainWindow);
2688+
this.notificationWindowManager.setHistoryManager(this.notificationHistoryWindowManager);
26852689
// Initialize BrowserWindow-based modal manager
26862690
this.modalWindowManager = new ModalWindowManager(this.mainWindow);
26872691

@@ -2717,6 +2721,7 @@ class ToolBoxApp {
27172721
this.toolWindowManager?.destroy();
27182722
this.toolWindowManager = null;
27192723
this.notificationWindowManager = null;
2724+
this.notificationHistoryWindowManager = null;
27202725
this.modalWindowManager = null;
27212726
this.mainWindow = null;
27222727
});

src/main/managers/notificationWindowManager.ts

Lines changed: 454 additions & 0 deletions
Large diffs are not rendered by default.

src/main/notificationPreload.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,6 @@ import { contextBridge, ipcRenderer } from "electron";
44
contextBridge.exposeInMainWorld("electron", {
55
dismissNotification: (index: number) => ipcRenderer.send("notification:dismiss", index),
66
actionClicked: (index: number, actionIndex: number) => ipcRenderer.send("notification:action", index, actionIndex),
7+
clearHistory: () => ipcRenderer.send("notification-history:clear"),
8+
closeHistoryPanel: () => ipcRenderer.send("notification-history:close"),
79
});

src/renderer/index.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,12 @@ <h2 class="whats-new-title">What's New</h2>
605605
<span class="footer-terminal-toggle-label">Show Terminal</span>
606606
</button>
607607
<span id="secondary-connection-status" class="secondary-connection-status"></span>
608+
<button type="button" class="footer-bell-btn" id="footer-notification-bell-btn" title="Notifications" aria-label="Notifications" aria-pressed="false" aria-haspopup="true">
609+
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
610+
<path d="M10 2a6 6 0 0 0-6 6v2.586l-.707.707A1 1 0 0 0 4 13h12a1 1 0 0 0 .707-1.707L16 10.586V8a6 6 0 0 0-6-6zM10 18a2 2 0 0 1-2-2h4a2 2 0 0 1-2 2z"/>
611+
</svg>
612+
<span class="notification-badge" id="notification-badge" hidden aria-label="unread notifications">0</span>
613+
</button>
608614
</div>
609615
</footer>
610616

src/renderer/modules/initialization.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { loadHomepageData, setupHomepageActions } from "./homepageManagement";
3030
import { clearMarketplaceDropdownFilters, handleProtocolInstallToolRequest, loadMarketplace, loadToolsLibrary } from "./marketplaceManagement";
3131
import { openAgentInvocationLogsTab } from "./mcpManagement";
3232
import { closeModal, openModal } from "./modalManagement";
33-
import { setDefaultNotificationDuration, showPPTBNotification } from "./notifications";
33+
import { setDefaultNotificationDuration, showPPTBNotification, initNotificationHistoryPanel } from "./notifications";
3434
import { openSettingsTab } from "./settingsManagement";
3535
import { switchSidebar } from "./sidebarManagement";
3636
import { handleTerminalClosed, handleTerminalCommandCompleted, handleTerminalCreated, handleTerminalError, handleTerminalOutput, setupTerminalPanel } from "./terminalManagement";
@@ -110,6 +110,10 @@ export async function initializeApplication(): Promise<void> {
110110
// Set up global search command palette
111111
initializeGlobalSearch();
112112

113+
// Set up notification history panel (bell icon in footer) early so the click
114+
// handler is registered before any async operations that might delay init.
115+
initNotificationHistoryPanel();
116+
113117
// Load and apply theme settings on startup
114118
await loadInitialSettings();
115119
logCheckpoint("Initial settings loaded");
@@ -804,6 +808,7 @@ function setupToolPanelBoundsListener(): void {
804808
width: Math.round(rect.width),
805809
height: adjustedHeight,
806810
};
811+
807812
logInfo("[Renderer] Sending tool panel bounds:", bounds);
808813
window.api.send("get-tool-panel-bounds-response", bounds);
809814
} else {

src/renderer/modules/notifications.ts

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@ function setupNotificationActionListener(): void {
9494

9595
/**
9696
* Show PPTB notification using dedicated BrowserWindow
97-
* Notifications are displayed in an always-on-top frameless window above the BrowserView
97+
* Notifications are displayed in an always-on-top frameless window above the BrowserView.
98+
* Each notification is also forwarded to the main process for persistent history tracking.
9899
*/
99100
export function showPPTBNotification(options: NotificationOptions): void {
100101
// Ensure the action listener is set up
@@ -113,20 +114,20 @@ export function showPPTBNotification(options: NotificationOptions): void {
113114
// remain available until the user explicitly dismisses the notification.
114115
const effectiveDuration = duration === 0 ? Number.MAX_SAFE_INTEGER - Date.now() : duration;
115116
const expiresAt = Date.now() + effectiveDuration + CALLBACK_TTL_BUFFER_MS;
116-
117+
117118
actions.forEach((action: { label: string; callback: string }, index: number) => {
118119
const originalCallback = options.actions![index].callback;
119120
notificationCallbacks.set(action.callback, {
120121
callback: originalCallback,
121122
expiresAt,
122123
});
123124
});
124-
125+
125126
// Start the cleanup interval to handle dismissed notifications
126127
startCleanupInterval();
127128
}
128129

129-
// Send to notification window manager via IPC
130+
// Send to notification window manager via IPC (also records in main-process history)
130131
window.api.invoke("notification:show", {
131132
title: options.title,
132133
body: options.body,
@@ -135,3 +136,69 @@ export function showPPTBNotification(options: NotificationOptions): void {
135136
actions,
136137
});
137138
}
139+
140+
// ── Notification History Panel ────────────────────────────────────────────────
141+
// The history panel is a separate always-on-top BrowserWindow managed by the
142+
// main process (NotificationHistoryWindowManager). The renderer is responsible
143+
// only for the bell button UI and badge update.
144+
145+
/** Whether the history panel window is currently open */
146+
let isPanelOpen = false;
147+
148+
/**
149+
* Initialize the notification history panel bell button.
150+
* Wires up the bell button click to open/close the main-process history window
151+
* and listens for badge-update and panel-closed events from the main process.
152+
* Must be called once after the DOM is ready.
153+
*/
154+
export function initNotificationHistoryPanel(): void {
155+
const bellBtn = document.getElementById("footer-notification-bell-btn");
156+
if (!bellBtn) return;
157+
158+
// Toggle the history window on bell button click
159+
bellBtn.addEventListener("click", (e) => {
160+
e.stopPropagation();
161+
if (isPanelOpen) {
162+
window.api.send("notification-history:close");
163+
} else {
164+
isPanelOpen = true;
165+
bellBtn.setAttribute("aria-pressed", "true");
166+
window.api.send("notification-history:open");
167+
}
168+
});
169+
170+
// Main process confirms the panel opened (updates aria-pressed)
171+
window.api.on("notification-history:opened", () => {
172+
isPanelOpen = true;
173+
bellBtn.setAttribute("aria-pressed", "true");
174+
});
175+
176+
// Main process notifies us when the panel closed (blur, Escape, or explicit close)
177+
window.api.on("notification-history:closed", () => {
178+
isPanelOpen = false;
179+
bellBtn.setAttribute("aria-pressed", "false");
180+
});
181+
182+
// Main process sends badge updates whenever the unread count changes.
183+
// window.api.on wraps ipcRenderer.on directly, so args[0] is the IPC event object
184+
// and args[1] is the first data payload (the unread count).
185+
window.api.on("notification:badge-update", (...args: unknown[]) => {
186+
const count = typeof args[1] === "number" ? args[1] : 0;
187+
updateBadge(count);
188+
});
189+
}
190+
191+
/** Update the badge element to reflect the current unread count */
192+
function updateBadge(count: number): void {
193+
const badge = document.getElementById("notification-badge");
194+
if (!badge) return;
195+
196+
if (count > 0) {
197+
const label = count > 99 ? "99+" : String(count);
198+
badge.textContent = label;
199+
badge.setAttribute("aria-label", `${label} unread notifications`);
200+
badge.hidden = false;
201+
} else {
202+
badge.hidden = true;
203+
}
204+
}

src/renderer/styles.scss

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5863,3 +5863,52 @@ body.dark-theme .global-search-item-badge.badge-settings {
58635863
font-weight: normal;
58645864
text-transform: none;
58655865
}
5866+
5867+
/* ── Notification Bell Button ───────────────────────────────────────────── */
5868+
5869+
.footer-bell-btn {
5870+
position: relative;
5871+
display: flex;
5872+
align-items: center;
5873+
justify-content: center;
5874+
width: 24px;
5875+
height: 24px;
5876+
padding: 0;
5877+
border: none;
5878+
background: transparent;
5879+
color: var(--text-secondary);
5880+
border-radius: 4px;
5881+
cursor: pointer;
5882+
transition: color 0.15s ease, background 0.15s ease;
5883+
flex-shrink: 0;
5884+
}
5885+
5886+
.footer-bell-btn:hover {
5887+
background: rgba(0, 0, 0, 0.08);
5888+
color: var(--text-color);
5889+
}
5890+
5891+
.dark-theme .footer-bell-btn:hover {
5892+
background: rgba(255, 255, 255, 0.1);
5893+
}
5894+
5895+
.footer-bell-btn[aria-pressed="true"] {
5896+
color: var(--accent-color, #0078d4);
5897+
}
5898+
5899+
.notification-badge {
5900+
position: absolute;
5901+
top: 1px;
5902+
right: 1px;
5903+
min-width: 14px;
5904+
height: 14px;
5905+
padding: 0 3px;
5906+
background: #d13438;
5907+
color: #ffffff;
5908+
font-size: 9px;
5909+
font-weight: 700;
5910+
line-height: 14px;
5911+
border-radius: 7px;
5912+
text-align: center;
5913+
pointer-events: none;
5914+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import type { ElectronApplication, Page } from "playwright";
2+
import { test, expect } from "./fixtures";
3+
4+
/**
5+
* E2E: Notification history bell button in the footer.
6+
*
7+
* The history panel is now a separate always-on-top BrowserWindow managed by
8+
* the main process (NotificationHistoryWindowManager). Clicking the bell
9+
* button in the footer opens/closes that window.
10+
*
11+
* Prerequisites: run `pnpm run build` before executing these tests.
12+
*/
13+
14+
/**
15+
* Click the bell button and wait for the newly-created history BrowserWindow
16+
* to appear. The history window is created lazily on first click.
17+
*/
18+
async function openHistoryWindow(electronApp: ElectronApplication, window: Page): Promise<Page> {
19+
const bellBtn = window.locator("#footer-notification-bell-btn");
20+
await expect(bellBtn).toBeVisible({ timeout: 15_000 });
21+
22+
const historyWindowPromise = electronApp.waitForEvent("window", { timeout: 10_000 });
23+
await bellBtn.click();
24+
const historyWindow = await historyWindowPromise;
25+
await historyWindow.waitForLoadState("domcontentloaded");
26+
return historyWindow;
27+
}
28+
29+
/** Check whether the Notification History BrowserWindow is currently visible. */
30+
async function isHistoryWindowVisible(electronApp: ElectronApplication): Promise<boolean> {
31+
return electronApp.evaluate(({ BrowserWindow }) => {
32+
const win = BrowserWindow.getAllWindows().find((w) => w.getTitle() === "Notification History");
33+
return win?.isVisible() ?? false;
34+
});
35+
}
36+
37+
test.describe("Notification history panel", () => {
38+
test("bell button is visible in the footer", async ({ window }) => {
39+
const bellBtn = window.locator("#footer-notification-bell-btn");
40+
await expect(bellBtn).toBeVisible({ timeout: 15_000 });
41+
});
42+
43+
test("notification badge is initially hidden", async ({ window }) => {
44+
const badge = window.locator("#notification-badge");
45+
await expect(badge).toBeHidden({ timeout: 15_000 });
46+
});
47+
48+
test("bell button aria-pressed is false initially", async ({ window }) => {
49+
const bellBtn = window.locator("#footer-notification-bell-btn");
50+
await expect(bellBtn).toBeVisible({ timeout: 15_000 });
51+
await expect(bellBtn).toHaveAttribute("aria-pressed", "false");
52+
});
53+
54+
test("clicking the bell button opens the history window", async ({ electronApp, window }) => {
55+
await openHistoryWindow(electronApp, window);
56+
57+
const isVisible = await isHistoryWindowVisible(electronApp);
58+
expect(isVisible).toBe(true);
59+
});
60+
61+
test("clicking the bell button again closes the history window", async ({ electronApp, window }) => {
62+
const bellBtn = window.locator("#footer-notification-bell-btn");
63+
await expect(bellBtn).toBeVisible({ timeout: 15_000 });
64+
65+
// Open the history window
66+
await openHistoryWindow(electronApp, window);
67+
await expect(bellBtn).toHaveAttribute("aria-pressed", "true", { timeout: 5_000 });
68+
69+
// Click bell again to close
70+
await bellBtn.click();
71+
await expect(bellBtn).toHaveAttribute("aria-pressed", "false", { timeout: 5_000 });
72+
73+
const isVisible = await isHistoryWindowVisible(electronApp);
74+
expect(isVisible).toBe(false);
75+
});
76+
77+
test("pressing Escape in the history window closes it", async ({ electronApp, window }) => {
78+
const bellBtn = window.locator("#footer-notification-bell-btn");
79+
await expect(bellBtn).toBeVisible({ timeout: 15_000 });
80+
81+
const historyWindow = await openHistoryWindow(electronApp, window);
82+
await expect(bellBtn).toHaveAttribute("aria-pressed", "true", { timeout: 5_000 });
83+
84+
await historyWindow.keyboard.press("Escape");
85+
await expect(bellBtn).toHaveAttribute("aria-pressed", "false", { timeout: 5_000 });
86+
});
87+
88+
test("empty state is shown when there are no notifications", async ({ electronApp, window }) => {
89+
const historyWindow = await openHistoryWindow(electronApp, window);
90+
91+
const emptyState = historyWindow.locator("#notification-history-empty");
92+
await expect(emptyState).toBeVisible({ timeout: 5_000 });
93+
await expect(emptyState).toHaveText(/no notifications yet/i);
94+
});
95+
96+
test("history window has a Clear All button", async ({ electronApp, window }) => {
97+
const historyWindow = await openHistoryWindow(electronApp, window);
98+
99+
const clearBtn = historyWindow.locator("#notification-clear-all-btn");
100+
await expect(clearBtn).toBeVisible({ timeout: 5_000 });
101+
});
102+
103+
test("bell button aria-pressed reflects window open state", async ({ electronApp, window }) => {
104+
const bellBtn = window.locator("#footer-notification-bell-btn");
105+
await expect(bellBtn).toBeVisible({ timeout: 15_000 });
106+
107+
await expect(bellBtn).toHaveAttribute("aria-pressed", "false");
108+
109+
await openHistoryWindow(electronApp, window);
110+
await expect(bellBtn).toHaveAttribute("aria-pressed", "true", { timeout: 5_000 });
111+
112+
await bellBtn.click();
113+
await expect(bellBtn).toHaveAttribute("aria-pressed", "false", { timeout: 5_000 });
114+
});
115+
116+
test("notification list is present inside the history window", async ({ electronApp, window }) => {
117+
const historyWindow = await openHistoryWindow(electronApp, window);
118+
119+
const list = historyWindow.locator("#notification-history-list");
120+
await expect(list).toBeAttached({ timeout: 5_000 });
121+
});
122+
});

0 commit comments

Comments
 (0)