notifications redesign

frdel committed Aug 2, 2025 at 13:04 UTC e37e7139cf3d188539cf72c7ea7b6e0f4696e65b
7 files changed +708 -644
docs/designs/backup-specification-frontend.md
+7 -7
@@ -820,12 +820,12 @@ const model = {
820
821 if (response.ok) {
822 const blob = await response.blob();
823 - const url = window.URL.createObjectURL(blob);
823 + const url = globalThis.URL.createObjectURL(blob);
824 const a = document.createElement('a');
825 a.href = url;
826 a.download = `${backupName}.zip`;
827 a.click();
828 - window.URL.revokeObjectURL(url);
828 + globalThis.URL.revokeObjectURL(url);
829 }
830 } catch (error) {
831 console.error('Download error:', error);
@@ -1298,9 +1298,9 @@ Use existing `openModal()` and `closeModal()` functions from the global modal sy
1298 Use existing Agent Zero toast system for consistent user feedback:
1299 ```javascript
1300 // Use established toast patterns
1301 -window.toast("Backup created successfully", "success");
1302 -window.toast("Restore completed", "success");
1303 -window.toast("Error creating backup", "error");
1301 +globalThis.toast("Backup created successfully", "success");
1302 +globalThis.toast("Restore completed", "success");
1303 +globalThis.toast("Error creating backup", "error");
1304 ```
1305
1306 #### ACE Editor Integration
@@ -1417,10 +1417,10 @@ formatTimestamp(timestamp) {
1417 // Use existing error handling patterns
1418 try {
1419 const result = await backupOperation();
1420 - window.toast("Operation completed successfully", "success");
1420 + globalThis.toast("Operation completed successfully", "success");
1421 } catch (error) {
1422 console.error('Backup error:', error);
1423 - window.toast(`Error: ${error.message}`, "error");
1423 + globalThis.toast(`Error: ${error.message}`, "error");
1424 }
1425 ```
1426
webui/components/notifications/notification-store.js
+611 -525
@@ -2,557 +2,643 @@ import { createStore } from "/js/AlpineStore.js";
2 import * as API from "/js/api.js";
3
4 const model = {
5 - notifications: [],
6 - loading: false,
7 - lastNotificationVersion: 0,
8 - lastNotificationGuid: "",
9 - unreadCount: 0,
10 -
11 - // NEW: Toast stack management
12 - toastStack: [],
13 - maxToastStack: 5,
14 -
15 - // Initialize the notification store
16 - initialize() {
17 - this.loading = true;
18 - this.updateUnreadCount();
19 - this.removeOldNotifications();
20 - this.toastStack = [];
21 -
22 - // Auto-cleanup old notifications and toasts
23 - setInterval(() => {
24 - this.removeOldNotifications();
25 - this.cleanupExpiredToasts();
26 - }, 5 * 60 * 1000); // Every 5 minutes
27 - },
28 -
29 - // Update notifications from polling data
30 - updateFromPoll(pollData) {
31 - if (!pollData) return;
32 -
33 - const isFirstLoad = !this.lastNotificationGuid && pollData.notifications_guid;
34 -
35 - // Check if GUID changed (system restart)
36 - if (pollData.notifications_guid !== this.lastNotificationGuid) {
37 - this.lastNotificationVersion = 0;
38 - this.notifications = [];
39 - this.toastStack = []; // Clear toast stack on restart
40 - this.lastNotificationGuid = pollData.notifications_guid || '';
41 - }
42 -
43 - // Process new notifications and add to toast stack
44 - if (pollData.notifications && pollData.notifications.length > 0) {
45 - pollData.notifications.forEach(notification => {
46 - const isNew = !this.notifications.find(n => n.id === notification.id);
47 - this.addOrUpdateNotification(notification);
48 -
49 - // Add new unread notifications to toast stack
50 - if (isNew && !notification.read) {
51 - this.addToToastStack(notification);
52 - }
53 - });
54 - }
55 -
56 - // Update version tracking
57 - this.lastNotificationVersion = pollData.notifications_version || 0;
58 - this.lastNotificationGuid = pollData.notifications_guid || '';
59 -
60 - // Update UI state
61 - this.updateUnreadCount();
62 - this.removeOldNotifications();
5 + notifications: [],
6 + loading: false,
7 + lastNotificationVersion: 0,
8 + lastNotificationGuid: "",
9 + unreadCount: 0,
10 +
11 + // NEW: Toast stack management
12 + toastStack: [],
13 + maxToastStack: 5,
14 +
15 + init() {
16 + this.initialize();
17 +
18 + setTimeout(function () {
19 + this.frontendInfo("Notification store initialized 1");
20 + // this.frontendInfo("Notification store initialized 2");
21 + // this.frontendInfo("Notification store initialized 3");
22 + }.bind(this), 100);
23 + },
24 +
25 + // Initialize the notification store
26 + initialize() {
27 + this.loading = true;
28 + this.updateUnreadCount();
29 + this.removeOldNotifications();
30 + this.toastStack = [];
31 +
32 + // Auto-cleanup old notifications and toasts
33 + setInterval(() => {
34 + this.removeOldNotifications();
35 + this.cleanupExpiredToasts();
36 + }, 5 * 60 * 1000); // Every 5 minutes
37 + },
38 +
39 + // Update notifications from polling data
40 + updateFromPoll(pollData) {
41 + if (!pollData) return;
42 +
43 + // Check if GUID changed (system restart)
44 + if (pollData.notifications_guid !== this.lastNotificationGuid) {
45 + this.lastNotificationVersion = 0;
46 + this.notifications = [];
47 + this.toastStack = []; // Clear toast stack on restart
48 + this.lastNotificationGuid = pollData.notifications_guid || "";
49 + }
50
64 - // Limit notifications to prevent memory issues (keep most recent)
65 - if (this.notifications.length > 50) {
66 - // Sort by timestamp and keep newest 50
67 - this.notifications.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
68 - this.notifications = this.notifications.slice(0, 50);
69 - }
70 - },
71 -
72 - // NEW: Add notification to toast stack
73 - addToToastStack(notification) {
74 - // If notification has a group, remove any existing toasts with the same group
75 - if (notification.group && notification.group.trim() !== "") {
76 - const existingToastIndex = this.toastStack.findIndex(t =>
77 - t.group === notification.group
78 - );
79 -
80 - if (existingToastIndex >= 0) {
81 - const existingToast = this.toastStack[existingToastIndex];
82 - if (existingToast.autoRemoveTimer) {
83 - clearTimeout(existingToast.autoRemoveTimer);
84 - }
85 - this.toastStack.splice(existingToastIndex, 1);
86 - }
87 - }
51 + // Process new notifications and add to toast stack
52 + if (pollData.notifications && pollData.notifications.length > 0) {
53 + pollData.notifications.forEach((notification) => {
54 + const isNew = !this.notifications.find((n) => n.id === notification.id);
55 + this.addOrUpdateNotification(notification);
56
89 - // Create toast object with auto-dismiss timer
90 - const toast = {
91 - ...notification,
92 - toastId: `toast-${notification.id}`,
93 - addedAt: Date.now(),
94 - autoRemoveTimer: null
95 - };
96 -
97 - // Add to bottom of stack (newest at bottom)
98 - this.toastStack.push(toast);
99 -
100 - // Enforce max stack limit (remove oldest from top)
101 - if (this.toastStack.length > this.maxToastStack) {
102 - const removed = this.toastStack.shift(); // Remove from top
103 - if (removed.autoRemoveTimer) {
104 - clearTimeout(removed.autoRemoveTimer);
105 - }
57 + // Add new unread notifications to toast stack
58 + if (isNew && !notification.read) {
59 + this.addToToastStack(notification);
60 }
61 + });
62 + }
63
108 - // Set auto-dismiss timer
109 - toast.autoRemoveTimer = setTimeout(() => {
110 - this.removeFromToastStack(toast.toastId);
111 - }, notification.display_time * 1000);
112 - },
113 -
114 - // NEW: Remove toast from stack
115 - removeFromToastStack(toastId) {
116 - const index = this.toastStack.findIndex(t => t.toastId === toastId);
117 - if (index >= 0) {
118 - const toast = this.toastStack[index];
119 - if (toast.autoRemoveTimer) {
120 - clearTimeout(toast.autoRemoveTimer);
121 - }
122 - this.toastStack.splice(index, 1);
123 - }
124 - },
125 -
126 - // NEW: Clear entire toast stack
127 - clearToastStack() {
128 - this.toastStack.forEach(toast => {
129 - if (toast.autoRemoveTimer) {
130 - clearTimeout(toast.autoRemoveTimer);
131 - }
132 - });
133 - this.toastStack = [];
134 - },
135 -
136 - // NEW: Clean up expired toasts (backup cleanup)
137 - cleanupExpiredToasts() {
138 - const now = Date.now();
139 - this.toastStack = this.toastStack.filter(toast => {
140 - const age = now - toast.addedAt;
141 - const maxAge = toast.display_time * 1000;
142 -
143 - if (age > maxAge) {
144 - if (toast.autoRemoveTimer) {
145 - clearTimeout(toast.autoRemoveTimer);
146 - }
147 - return false;
148 - }
149 - return true;
150 - });
151 - },
152 -
153 - // NEW: Handle toast click (opens modal)
154 - async handleToastClick(toastId) {
155 - await this.openModal();
156 - // Modal opening will clear toast stack via markAllAsRead
157 - },
158 -
159 - // Add or update a notification
160 - addOrUpdateNotification(notification) {
161 - const existingIndex = this.notifications.findIndex(n => n.id === notification.id);
162 -
163 - if (existingIndex >= 0) {
164 - // Update existing notification
165 - this.notifications[existingIndex] = notification;
166 - } else {
167 - // Add new notification at the beginning (most recent first)
168 - this.notifications.unshift(notification);
169 - }
170 - },
171 -
172 - // Update unread count
173 - updateUnreadCount() {
174 - this.unreadCount = this.notifications.filter(n => !n.read).length;
175 - },
176 -
177 - // Mark notification as read
178 - async markAsRead(notificationId) {
179 - const notification = this.notifications.find(n => n.id === notificationId);
180 - if (notification && !notification.read) {
181 - notification.read = true;
182 - this.updateUnreadCount();
183 -
184 - // Sync with backend (non-blocking)
185 - try {
186 - await API.callJsonApi('notifications_mark_read', {
187 - notification_ids: [notificationId]
188 - });
189 - } catch (error) {
190 - console.error('Failed to sync notification read status:', error);
191 - // Don't revert the UI change - user experience should not be affected
192 - }
64 + // Update version tracking
65 + this.lastNotificationVersion = pollData.notifications_version || 0;
66 + this.lastNotificationGuid = pollData.notifications_guid || "";
67 +
68 + // Update UI state
69 + this.updateUnreadCount();
70 + // this.removeOldNotifications();
71 +
72 + // Limit notifications to prevent memory issues (keep most recent)
73 + if (this.notifications.length > 50) {
74 + // Sort by timestamp and keep newest 50
75 + this.notifications.sort(
76 + (a, b) => new Date(b.timestamp) - new Date(a.timestamp)
77 + );
78 + this.notifications = this.notifications.slice(0, 50);
79 + }
80 + },
81 +
82 + // NEW: Add notification to toast stack
83 + addToToastStack(notification) {
84 + // If notification has a group, remove any existing toasts with the same group
85 + if (notification.group && notification.group.trim() !== "") {
86 + const existingToastIndex = this.toastStack.findIndex(
87 + (t) => t.group === notification.group
88 + );
89 +
90 + if (existingToastIndex >= 0) {
91 + const existingToast = this.toastStack[existingToastIndex];
92 + if (existingToast.autoRemoveTimer) {
93 + clearTimeout(existingToast.autoRemoveTimer);
94 }
194 - },
95 + this.toastStack.splice(existingToastIndex, 1);
96 + }
97 + }
98
196 - // Enhanced: Mark all as read and clear toast stack
197 - async markAllAsRead() {
198 - const unreadNotifications = this.notifications.filter(n => !n.read);
199 - if (unreadNotifications.length === 0) return;
99 + // Create toast object with auto-dismiss timer
100 + const toast = {
101 + ...notification,
102 + toastId: `toast-${notification.id}`,
103 + addedAt: Date.now(),
104 + autoRemoveTimer: null,
105 + };
106 +
107 + // Add to bottom of stack (newest at bottom)
108 + this.toastStack.push(toast);
109 +
110 + // Enforce max stack limit (remove oldest from top)
111 + if (this.toastStack.length > this.maxToastStack) {
112 + const removed = this.toastStack.shift(); // Remove from top
113 + if (removed.autoRemoveTimer) {
114 + clearTimeout(removed.autoRemoveTimer);
115 + }
116 + }
117
201 - // Update UI immediately
202 - this.notifications.forEach(notification => {
203 - notification.read = true;
204 - });
205 - this.updateUnreadCount();
206 -
207 - // Clear toast stack when marking all as read
208 - this.clearToastStack();
209 -
210 - // Sync with backend (non-blocking)
211 - try {
212 - await API.callJsonApi('notifications_mark_read', {
213 - mark_all: true
214 - });
215 - } catch (error) {
216 - console.error('Failed to sync mark all as read:', error);
118 + // Set auto-dismiss timer
119 + toast.autoRemoveTimer = setTimeout(() => {
120 + this.removeFromToastStack(toast.toastId);
121 + }, notification.display_time * 1000);
122 + },
123 +
124 + // NEW: Remove toast from stack
125 + removeFromToastStack(toastId) {
126 + const index = this.toastStack.findIndex((t) => t.toastId === toastId);
127 + if (index >= 0) {
128 + const toast = this.toastStack[index];
129 + if (toast.autoRemoveTimer) {
130 + clearTimeout(toast.autoRemoveTimer);
131 + }
132 + this.toastStack.splice(index, 1);
133 + }
134 + },
135 +
136 + // NEW: Clear entire toast stack
137 + clearToastStack() {
138 + this.toastStack.forEach((toast) => {
139 + if (toast.autoRemoveTimer) {
140 + clearTimeout(toast.autoRemoveTimer);
141 + }
142 + });
143 + this.toastStack = [];
144 + },
145 +
146 + // NEW: Clean up expired toasts (backup cleanup)
147 + cleanupExpiredToasts() {
148 + const now = Date.now();
149 + this.toastStack = this.toastStack.filter((toast) => {
150 + const age = now - toast.addedAt;
151 + const maxAge = toast.display_time * 1000;
152 +
153 + if (age > maxAge) {
154 + if (toast.autoRemoveTimer) {
155 + clearTimeout(toast.autoRemoveTimer);
156 }
218 - },
219 -
220 - // Clear all notifications
221 - async clearAll() {
222 - this.notifications = [];
223 - this.unreadCount = 0;
224 - this.clearToastStack(); // Also clear toast stack
225 -
226 - // Note: We don't sync clear with backend as notifications are stored in memory only
227 - },
228 -
229 - // Get notifications by type
230 - getNotificationsByType(type) {
231 - return this.notifications.filter(n => n.type === type);
232 - },
233 -
234 - // Get notifications for display: ALL unread + read from last 5 minutes
235 - getDisplayNotifications() {
236 - const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
237 -
238 - return this.notifications.filter(notification => {
239 - // Always show unread notifications
240 - if (!notification.read) {
241 - return true;
242 - }
243 -
244 - // Show read notifications only if they're from the last 5 minutes
245 - const notificationDate = new Date(notification.timestamp);
246 - return notificationDate > fiveMinutesAgo;
157 + return false;
158 + }
159 + return true;
160 + });
161 + },
162 +
163 + // NEW: Handle toast click (opens modal)
164 + async handleToastClick(toastId) {
165 + await this.openModal();
166 + // Modal opening will clear toast stack via markAllAsRead
167 + },
168 +
169 + // Add or update a notification
170 + addOrUpdateNotification(notification) {
171 + const existingIndex = this.notifications.findIndex(
172 + (n) => n.id === notification.id
173 + );
174 +
175 + if (existingIndex >= 0) {
176 + // Update existing notification
177 + this.notifications[existingIndex] = notification;
178 + } else {
179 + // Add new notification at the beginning (most recent first)
180 + this.notifications.unshift(notification);
181 + }
182 + },
183 +
184 + // Update unread count
185 + updateUnreadCount() {
186 + const unread = this.notifications.filter((n) => !n.read).length;
187 + if (this.unreadCount !== unread) this.unreadCount = unread;
188 + },
189 +
190 + // Mark notification as read
191 + async markAsRead(notificationId) {
192 + const notification = this.notifications.find(
193 + (n) => n.id === notificationId
194 + );
195 + if (notification && !notification.read) {
196 + notification.read = true;
197 + this.updateUnreadCount();
198 +
199 + // Sync with backend (non-blocking)
200 + try {
201 + await API.callJsonApi("notifications_mark_read", {
202 + notification_ids: [notificationId],
203 });
248 - },
249 -
250 - // Get recent notifications (last 5) - kept for backwards compatibility
251 - getRecentNotifications() {
252 - return this.notifications.slice(0, 5);
253 - },
254 -
255 - // Get notification by ID
256 - getNotificationById(id) {
257 - return this.notifications.find(n => n.id === id);
258 - },
259 -
260 - // Remove old notifications (older than 1 hour)
261 - removeOldNotifications() {
262 - const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
263 - const initialCount = this.notifications.length;
264 - this.notifications = this.notifications.filter(n =>
265 - new Date(n.timestamp) > oneHourAgo
266 - );
267 -
268 - if (this.notifications.length !== initialCount) {
269 - this.updateUnreadCount();
270 - }
271 - },
272 -
273 - // Format timestamp for display
274 - formatTimestamp(timestamp) {
275 - const date = new Date(timestamp);
276 - const now = new Date();
277 - const diffMs = now - date;
278 - const diffMins = Math.floor(diffMs / 60000);
279 - const diffHours = Math.floor(diffMs / 3600000);
280 - const diffDays = Math.floor(diffMs / 86400000);
281 -
282 - if (diffMins < 1) return 'Just now';
283 - if (diffMins < 60) return `${diffMins}m ago`;
284 - if (diffHours < 24) return `${diffHours}h ago`;
285 - if (diffDays < 7) return `${diffDays}d ago`;
286 -
287 - return date.toLocaleDateString();
288 - },
289 -
290 - // Get CSS class for notification type
291 - getNotificationClass(type) {
292 - const classes = {
293 - info: "notification-info",
294 - success: "notification-success",
295 - warning: "notification-warning",
296 - error: "notification-error",
297 - progress: "notification-progress"
298 - };
299 - return classes[type] || "notification-info";
300 - },
301 -
302 - // Get CSS class for notification item including read state
303 - getNotificationItemClass(notification) {
304 - const typeClass = this.getNotificationClass(notification.type);
305 - const readClass = notification.read ? "read" : "unread";
306 - return `notification-item ${typeClass} ${readClass}`;
307 - },
308 -
309 - // Get icon for notification type (Google Material Icons)
310 - getNotificationIcon(type) {
311 - const icons = {
312 - info: "info",
313 - success: "check_circle",
314 - warning: "warning",
315 - error: "error",
316 - progress: "hourglass_empty"
317 - };
318 - const iconName = icons[type] || "info";
319 - return `<span class="material-symbols-outlined">${iconName}</span>`;
320 - },
321 -
322 - // Create notification via backend (will appear via polling)
323 - async createNotification(type, message, title = "", detail = "", display_time = 3, group = "") {
324 - try {
325 - const response = await window.sendJsonData('/notification_create', {
326 - type: type,
327 - message: message,
328 - title: title,
329 - detail: detail,
330 - display_time: display_time,
331 - group: group
332 - });
333 -
334 - if (response.success) {
335 - return response.notification_id;
336 - } else {
337 - console.error('Failed to create notification:', response.error);
338 - return null;
339 - }
340 - } catch (error) {
341 - console.error('Error creating notification:', error);
342 - return null;
343 - }
344 - },
345 -
346 - // Convenience methods for different notification types
347 - async info(message, title = "", detail = "", display_time = 3, group = "") {
348 - return await this.createNotification('info', message, title, detail, display_time, group);
349 - },
350 -
351 - async success(message, title = "", detail = "", display_time = 3, group = "") {
352 - return await this.createNotification('success', message, title, detail, display_time, group);
353 - },
354 -
355 - async warning(message, title = "", detail = "", display_time = 3, group = "") {
356 - return await this.createNotification('warning', message, title, detail, display_time, group);
357 - },
358 -
359 - async error(message, title = "", detail = "", display_time = 3, group = "") {
360 - return await this.createNotification('error', message, title, detail, display_time, group);
361 - },
362 -
363 - async progress(message, title = "", detail = "", display_time = 3, group = "") {
364 - return await this.createNotification('progress', message, title, detail, display_time, group);
365 - },
366 -
367 - // Enhanced: Open modal and clear toast stack
368 - async openModal() {
369 - // Import the standard modal system
370 - const { openModal } = await import("/js/modals.js");
371 - await openModal("notifications/notification-modal.html");
372 -
373 - // Clear toast stack when modal opens
374 - this.clearToastStack();
375 - },
376 -
377 - // Legacy method for backward compatibility
378 - toggleNotifications() {
379 - this.openModal();
380 - },
381 -
382 - // NEW: Check if backend connection is available
383 - isConnected() {
384 - // Use the global connection status from index.js, but default to true if undefined
385 - // This handles the case where polling hasn't run yet but backend is actually available
386 - const pollingStatus = typeof window.getConnectionStatus === 'function' ? window.getConnectionStatus() : undefined;
387 -
388 - // If polling status is explicitly false, respect that
389 - if (pollingStatus === false) {
390 - return false;
391 - }
392 -
393 - // If polling status is undefined/true, assume backend is available
394 - // (since the page loaded successfully, backend must be working)
395 - return true;
396 - },
397 -
398 - // NEW: Add frontend-only toast directly to stack (renamed from original addFrontendToast)
399 - addFrontendToastOnly(type, message, title = "", display_time = 5, group = "") {
400 - const timestamp = new Date().toISOString();
401 - const notification = {
402 - id: `frontend-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
403 - type: type,
404 - title: title,
405 - message: message,
406 - detail: "",
407 - timestamp: timestamp,
408 - display_time: display_time,
409 - read: false,
410 - frontend: true, // Mark as frontend-only
411 - group: group
412 - };
413 -
414 - // If notification has a group, remove any existing toasts with the same group
415 - if (group && group.trim() !== "") {
416 - const existingToastIndex = this.toastStack.findIndex(t =>
417 - t.group === group
418 - );
419 -
420 - if (existingToastIndex >= 0) {
421 - const existingToast = this.toastStack[existingToastIndex];
422 - if (existingToast.autoRemoveTimer) {
423 - clearTimeout(existingToast.autoRemoveTimer);
424 - }
425 - this.toastStack.splice(existingToastIndex, 1);
426 - }
427 - }
428 -
429 - // Create toast object with auto-dismiss timer
430 - const toast = {
431 - ...notification,
432 - toastId: `toast-${notification.id}`,
433 - addedAt: Date.now(),
434 - autoRemoveTimer: null
435 - };
436 -
437 - // Add to bottom of stack (newest at bottom)
438 - this.toastStack.push(toast);
439 -
440 - // Enforce max stack limit (remove oldest from top)
441 - if (this.toastStack.length > this.maxToastStack) {
442 - const removed = this.toastStack.shift(); // Remove from top
443 - if (removed.autoRemoveTimer) {
444 - clearTimeout(removed.autoRemoveTimer);
445 - }
446 - }
204 + } catch (error) {
205 + console.error("Failed to sync notification read status:", error);
206 + // Don't revert the UI change - user experience should not be affected
207 + }
208 + }
209 + },
210 +
211 + // Enhanced: Mark all as read and clear toast stack
212 + async markAllAsRead() {
213 + const unreadNotifications = this.notifications.filter((n) => !n.read);
214 + if (unreadNotifications.length === 0) return;
215 +
216 + // Update UI immediately
217 + this.notifications.forEach((notification) => {
218 + notification.read = true;
219 + });
220 + this.updateUnreadCount();
221 +
222 + // Clear toast stack when marking all as read
223 + this.clearToastStack();
224 +
225 + // Sync with backend (non-blocking)
226 + try {
227 + await API.callJsonApi("notifications_mark_read", {
228 + mark_all: true,
229 + });
230 + } catch (error) {
231 + console.error("Failed to sync mark all as read:", error);
232 + }
233 + },
234
448 - // Set auto-dismiss timer
449 - toast.autoRemoveTimer = setTimeout(() => {
450 - this.removeFromToastStack(toast.toastId);
451 - }, notification.display_time * 1000);
452 -
453 - return notification.id;
454 - },
455 -
456 - // NEW: Enhanced frontend toast that tries backend first, falls back to frontend-only
457 - async addFrontendToast(type, message, title = "", display_time = 5, group = "") {
458 - // Try to send to backend first if connected
459 - if (this.isConnected()) {
460 - try {
461 - const notificationId = await this.createNotification(type, message, title, "", display_time, group);
462 - if (notificationId) {
463 - // Backend handled it, notification will arrive via polling
464 - return notificationId;
465 - }
466 - } catch (error) {
467 - console.log(`Backend unavailable for notification, showing as frontend-only: ${error.message || error}`);
468 - }
469 - } else {
470 - console.log('Backend disconnected, showing as frontend-only toast');
471 - }
235 + // Clear all notifications
236 + async clearAll() {
237 + this.notifications = [];
238 + this.unreadCount = 0;
239 + this.clearToastStack(); // Also clear toast stack
240
473 - // Fallback to frontend-only toast
474 - return this.addFrontendToastOnly(type, message, title, display_time, group);
475 - },
241 + // Note: We don't sync clear with backend as notifications are stored in memory only
242 + },
243
477 - // NEW: Convenience methods for frontend notifications (updated to use new backend-first logic)
478 - async frontendError(message, title = "Connection Error", display_time = 8, group = "") {
479 - return await this.addFrontendToast('error', message, title, display_time, group);
480 - },
244 + // Get notifications by type
245 + getNotificationsByType(type) {
246 + return this.notifications.filter((n) => n.type === type);
247 + },
248
482 - async frontendWarning(message, title = "Warning", display_time = 5, group = "") {
483 - return await this.addFrontendToast('warning', message, title, display_time, group);
484 - },
249 + // Get notifications for display: ALL unread + read from last 5 minutes
250 + getDisplayNotifications() {
251 + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
252
486 - async frontendInfo(message, title = "Info", display_time = 3, group = "") {
487 - return await this.addFrontendToast('info', message, title, display_time, group);
253 + return this.notifications.filter((notification) => {
254 + // Always show unread notifications
255 + if (!notification.read) {
256 + return true;
257 + }
258 +
259 + // Show read notifications only if they're from the last 5 minutes
260 + const notificationDate = new Date(notification.timestamp);
261 + return notificationDate > fiveMinutesAgo;
262 + });
263 + },
264 +
265 + // Get recent notifications (last 5) - kept for backwards compatibility
266 + getRecentNotifications() {
267 + return this.notifications.slice(0, 5);
268 + },
269 +
270 + // Get notification by ID
271 + getNotificationById(id) {
272 + return this.notifications.find((n) => n.id === id);
273 + },
274 +
275 + // Remove old notifications (older than 1 hour)
276 + removeOldNotifications() {
277 + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
278 + const initialCount = this.notifications.length;
279 + this.notifications = this.notifications.filter(
280 + (n) => new Date(n.timestamp) > oneHourAgo
281 + );
282 +
283 + if (this.notifications.length !== initialCount) {
284 + this.updateUnreadCount();
285 }
489 -};
490 -
491 -// Create and export the store
492 -const store = createStore("notificationStore", model);
493 -
494 -// NEW: Global function for frontend error toasts (replaces toastFetchError)
495 -window.toastFrontendError = async function(message, title = "Connection Error", display_time = 8, group = "") {
496 - if (window.Alpine && window.Alpine.store && window.Alpine.store('notificationStore')) {
497 - try {
498 - return await window.Alpine.store('notificationStore').addFrontendToast('error', message, title, display_time, group);
499 - } catch (error) {
500 - console.error('Failed to create frontend error notification:', error);
501 - // Fallback to console if something goes wrong
502 - console.error('Frontend Error:', title, '-', message);
503 - return null;
504 - }
505 - } else {
506 - // Fallback if Alpine/store not ready
507 - console.error('Frontend Error:', title, '-', message);
286 + },
287 +
288 + // Format timestamp for display
289 + formatTimestamp(timestamp) {
290 + const date = new Date(timestamp);
291 + const now = new Date();
292 + const diffMs = now - date;
293 + const diffMins = Math.floor(diffMs / 60000);
294 + const diffHours = Math.floor(diffMs / 3600000);
295 + const diffDays = Math.floor(diffMs / 86400000);
296 +
297 + if (diffMins < 1) return "Just now";
298 + if (diffMins < 60) return `${diffMins}m ago`;
299 + if (diffHours < 24) return `${diffHours}h ago`;
300 + if (diffDays < 7) return `${diffDays}d ago`;
301 +
302 + return date.toLocaleDateString();
303 + },
304 +
305 + // Get CSS class for notification type
306 + getNotificationClass(type) {
307 + const classes = {
308 + info: "notification-info",
309 + success: "notification-success",
310 + warning: "notification-warning",
311 + error: "notification-error",
312 + progress: "notification-progress",
313 + };
314 + return classes[type] || "notification-info";
315 + },
316 +
317 + // Get CSS class for notification item including read state
318 + getNotificationItemClass(notification) {
319 + const typeClass = this.getNotificationClass(notification.type);
320 + const readClass = notification.read ? "read" : "unread";
321 + return `notification-item ${typeClass} ${readClass}`;
322 + },
323 +
324 + // Get icon for notification type (Google Material Icons)
325 + getNotificationIcon(type) {
326 + const icons = {
327 + info: "info",
328 + success: "check_circle",
329 + warning: "warning",
330 + error: "error",
331 + progress: "hourglass_empty",
332 + };
333 + const iconName = icons[type] || "info";
334 + return `<span class="material-symbols-outlined">${iconName}</span>`;
335 + },
336 +
337 + // Create notification via backend (will appear via polling)
338 + async createNotification(
339 + type,
340 + message,
341 + title = "",
342 + detail = "",
343 + display_time = 3,
344 + group = ""
345 + ) {
346 + try {
347 + const response = await globalThis.sendJsonData("/notification_create", {
348 + type: type,
349 + message: message,
350 + title: title,
351 + detail: detail,
352 + display_time: display_time,
353 + group: group,
354 + });
355 +
356 + if (response.success) {
357 + return response.notification_id;
358 + } else {
359 + console.error("Failed to create notification:", response.error);
360 return null;
361 + }
362 + } catch (error) {
363 + console.error("Error creating notification:", error);
364 + return null;
365 + }
366 + },
367 +
368 + // Convenience methods for different notification types
369 + async info(message, title = "", detail = "", display_time = 3, group = "") {
370 + return await this.createNotification(
371 + "info",
372 + message,
373 + title,
374 + detail,
375 + display_time,
376 + group
377 + );
378 + },
379 +
380 + async success(
381 + message,
382 + title = "",
383 + detail = "",
384 + display_time = 3,
385 + group = ""
386 + ) {
387 + return await this.createNotification(
388 + "success",
389 + message,
390 + title,
391 + detail,
392 + display_time,
393 + group
394 + );
395 + },
396 +
397 + async warning(
398 + message,
399 + title = "",
400 + detail = "",
401 + display_time = 3,
402 + group = ""
403 + ) {
404 + return await this.createNotification(
405 + "warning",
406 + message,
407 + title,
408 + detail,
409 + display_time,
410 + group
411 + );
412 + },
413 +
414 + async error(message, title = "", detail = "", display_time = 3, group = "") {
415 + return await this.createNotification(
416 + "error",
417 + message,
418 + title,
419 + detail,
420 + display_time,
421 + group
422 + );
423 + },
424 +
425 + async progress(
426 + message,
427 + title = "",
428 + detail = "",
429 + display_time = 3,
430 + group = ""
431 + ) {
432 + return await this.createNotification(
433 + "progress",
434 + message,
435 + title,
436 + detail,
437 + display_time,
438 + group
439 + );
440 + },
441 +
442 + // Enhanced: Open modal and clear toast stack
443 + async openModal() {
444 + // Import the standard modal system
445 + const { openModal } = await import("/js/modals.js");
446 + await openModal("notifications/notification-modal.html");
447 +
448 + // Clear toast stack when modal opens
449 + this.clearToastStack();
450 + },
451 +
452 + // Legacy method for backward compatibility
453 + toggleNotifications() {
454 + this.openModal();
455 + },
456 +
457 + // NEW: Check if backend connection is available
458 + isConnected() {
459 + // Use the global connection status from index.js, but default to true if undefined
460 + // This handles the case where polling hasn't run yet but backend is actually available
461 + const pollingStatus =
462 + typeof globalThis.getConnectionStatus === "function"
463 + ? globalThis.getConnectionStatus()
464 + : undefined;
465 +
466 + // If polling status is explicitly false, respect that
467 + if (pollingStatus === false) {
468 + return false;
469 }
510 -};
470
512 -// NEW: Additional global convenience functions
513 -window.toastFrontendWarning = async function(message, title = "Warning", display_time = 5, group = "") {
514 - if (window.Alpine && window.Alpine.store && window.Alpine.store('notificationStore')) {
515 - try {
516 - return await window.Alpine.store('notificationStore').addFrontendToast('warning', message, title, display_time, group);
517 - } catch (error) {
518 - console.error('Failed to create frontend warning notification:', error);
519 - console.warn('Frontend Warning:', title, '-', message);
520 - return null;
471 + // If polling status is undefined/true, assume backend is available
472 + // (since the page loaded successfully, backend must be working)
473 + return true;
474 + },
475 +
476 + // NEW: Add frontend-only toast directly to stack (renamed from original addFrontendToast)
477 + addFrontendToastOnly(
478 + type,
479 + message,
480 + title = "",
481 + display_time = 5,
482 + group = ""
483 + ) {
484 + const timestamp = new Date().toISOString();
485 + const notification = {
486 + id: `frontend-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
487 + type: type,
488 + title: title,
489 + message: message,
490 + detail: "",
491 + timestamp: timestamp,
492 + display_time: display_time,
493 + read: false,
494 + frontend: true, // Mark as frontend-only
495 + group: group,
496 + };
497 +
498 + // If notification has a group, remove any existing toasts with the same group
499 + if (group && group.trim() !== "") {
500 + const existingToastIndex = this.toastStack.findIndex(
501 + (t) => t.group === group
502 + );
503 +
504 + if (existingToastIndex >= 0) {
505 + const existingToast = this.toastStack[existingToastIndex];
506 + if (existingToast.autoRemoveTimer) {
507 + clearTimeout(existingToast.autoRemoveTimer);
508 }
522 - } else {
523 - console.warn('Frontend Warning:', title, '-', message);
524 - return null;
509 + this.toastStack.splice(existingToastIndex, 1);
510 + }
511 }
526 -};
512
528 -window.toastFrontendInfo = async function(message, title = "Info", display_time = 3, group = "") {
529 - if (window.Alpine && window.Alpine.store && window.Alpine.store('notificationStore')) {
530 - try {
531 - return await window.Alpine.store('notificationStore').addFrontendToast('info', message, title, display_time, group);
532 - } catch (error) {
533 - console.error('Failed to create frontend info notification:', error);
534 - console.log('Frontend Info:', title, '-', message);
535 - return null;
536 - }
537 - } else {
538 - console.log('Frontend Info:', title, '-', message);
539 - return null;
513 + // Create toast object with auto-dismiss timer
514 + const toast = {
515 + ...notification,
516 + toastId: `toast-${notification.id}`,
517 + addedAt: Date.now(),
518 + autoRemoveTimer: null,
519 + };
520 +
521 + // Add to bottom of stack (newest at bottom)
522 + this.toastStack.push(toast);
523 +
524 + // Enforce max stack limit (remove oldest from top)
525 + if (this.toastStack.length > this.maxToastStack) {
526 + const removed = this.toastStack.shift(); // Remove from top
527 + if (removed.autoRemoveTimer) {
528 + clearTimeout(removed.autoRemoveTimer);
529 + }
530 }
541 -};
531
543 -window.toastFrontendSuccess = async function(message, title = "Success", display_time = 3, group = "") {
544 - if (window.Alpine && window.Alpine.store && window.Alpine.store('notificationStore')) {
545 - try {
546 - return await window.Alpine.store('notificationStore').addFrontendToast('success', message, title, display_time, group);
547 - } catch (error) {
548 - console.error('Failed to create frontend success notification:', error);
549 - console.log('Frontend Success:', title, '-', message);
550 - return null;
532 + // Set auto-dismiss timer
533 + toast.autoRemoveTimer = setTimeout(() => {
534 + this.removeFromToastStack(toast.toastId);
535 + }, notification.display_time * 1000);
536 +
537 + return notification.id;
538 + },
539 +
540 + // NEW: Enhanced frontend toast that tries backend first, falls back to frontend-only
541 + async addFrontendToast(
542 + type,
543 + message,
544 + title = "",
545 + display_time = 5,
546 + group = ""
547 + ) {
548 + // Try to send to backend first if connected
549 + if (this.isConnected()) {
550 + try {
551 + const notificationId = await this.createNotification(
552 + type,
553 + message,
554 + title,
555 + "",
556 + display_time,
557 + group
558 + );
559 + if (notificationId) {
560 + // Backend handled it, notification will arrive via polling
561 + return notificationId;
562 }
563 + } catch (error) {
564 + console.log(
565 + `Backend unavailable for notification, showing as frontend-only: ${
566 + error.message || error
567 + }`
568 + );
569 + }
570 } else {
553 - console.log('Frontend Success:', title, '-', message);
554 - return null;
571 + console.log("Backend disconnected, showing as frontend-only toast");
572 }
573 +
574 + // Fallback to frontend-only toast
575 + return this.addFrontendToastOnly(type, message, title, display_time, group);
576 + },
577 +
578 + // NEW: Convenience methods for frontend notifications (updated to use new backend-first logic)
579 + async frontendError(
580 + message,
581 + title = "Connection Error",
582 + display_time = 8,
583 + group = ""
584 + ) {
585 + return await this.addFrontendToast(
586 + "error",
587 + message,
588 + title,
589 + display_time,
590 + group
591 + );
592 + },
593 +
594 + async frontendWarning(
595 + message,
596 + title = "Warning",
597 + display_time = 5,
598 + group = ""
599 + ) {
600 + return await this.addFrontendToast(
601 + "warning",
602 + message,
603 + title,
604 + display_time,
605 + group
606 + );
607 + },
608 +
609 + async frontendInfo(message, title = "Info", display_time = 3, group = "") {
610 + return await this.addFrontendToast(
611 + "info",
612 + message,
613 + title,
614 + display_time,
615 + group
616 + );
617 + },
618 +
619 + async frontendSuccess(
620 + message,
621 + title = "Success",
622 + display_time = 3,
623 + group = ""
624 + ) {
625 + return await this.addFrontendToast(
626 + "success",
627 + message,
628 + title,
629 + display_time,
630 + group
631 + );
632 + },
633 };
634
635 +// Create and export the store
636 +const store = createStore("notificationStore", model);
637 export { store };
638 +
639 +
640 +// add toasts to global for backward compatibility with older scripts
641 +globalThis.toastFrontendInfo = store.frontendInfo.bind(store);
642 +globalThis.toastFrontendSuccess = store.frontendSuccess.bind(store);
643 +globalThis.toastFrontendWarning = store.frontendWarning.bind(store);
644 +globalThis.toastFrontendError = store.frontendError.bind(store);
webui/components/notifications/notification-toast-stack.html
+20 -45
@@ -33,9 +33,9 @@
33 <div class="toast-message"
34 x-text="toast.message">
35 </div>
36 - <div class="toast-timestamp"
36 + <!-- <div class="toast-timestamp"
37 x-text="$store.notificationStore.formatTimestamp(toast.timestamp)">
38 - </div>
38 + </div> -->
39 </div>
40
41 <!-- Toast Dismiss -->
@@ -53,16 +53,17 @@
53 <style>
54 /* Toast Stack Container */
55 .toast-stack-container {
56 - position: fixed;
57 - bottom: 20px;
58 - right: 20px;
56 + position: absolute;
57 + bottom: 5px; /* Spacing from the bottom of the zero-height container */
58 + padding-right: 5px;
59 z-index: 1500;
60 display: flex;
61 - flex-direction: column;
61 + flex-direction: column-reverse; /* Stack toasts upwards */
62 gap: 8px;
63 pointer-events: none;
64 max-width: 400px;
65 - width: 100%;
65 + width: calc(100% - 10px);
66 + align-items: flex-end;
67 }
68
69 /* Individual Toast Items */
@@ -72,20 +73,18 @@
73 align-items: flex-start;
74 gap: 12px;
75 padding: 16px;
75 - background: rgba(0, 0, 0, 0.9);
76 - border: 1px solid rgba(255, 255, 255, 0.2);
76 + background: var(--color-panel);
77 + border: 1px solid var(--color-border);
78 border-radius: 8px;
79 cursor: pointer;
80 transition: all 0.2s ease;
80 - backdrop-filter: blur(10px);
81 + /* backdrop-filter: blur(10px); */
82 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
83 position: relative;
84 min-height: 60px;
85 }
86
87 .toast-item:hover {
87 - background: rgba(0, 0, 0, 0.95);
88 - border-color: rgba(255, 255, 255, 0.3);
88 transform: translateY(-2px);
89 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
90 }
@@ -93,34 +92,33 @@
92 /* Toast Type Styling */
93 .toast-item.notification-info {
94 border-left: 4px solid #2196F3;
96 - background: rgba(33, 150, 243, 0.1);
95 + /* background: rgba(33, 150, 243, 0.1); */
96 }
97
98 .toast-item.notification-success {
99 border-left: 4px solid #4CAF50;
101 - background: rgba(76, 175, 80, 0.1);
100 + /* background: rgba(76, 175, 80, 0.1); */
101 }
102
103 .toast-item.notification-warning {
104 border-left: 4px solid #FF9800;
106 - background: rgba(255, 152, 0, 0.1);
105 + /* background: rgba(255, 152, 0, 0.1); */
106 }
107
108 .toast-item.notification-error {
109 border-left: 4px solid #F44336;
111 - background: rgba(244, 67, 54, 0.1);
110 + /* background: rgba(244, 67, 54, 0.1); */
111 }
112
113 .toast-item.notification-progress {
114 border-left: 4px solid #9C27B0;
116 - background: rgba(156, 39, 176, 0.1);
115 + /* background: rgba(156, 39, 176, 0.1); */
116 }
117
118 /* Toast Icon */
119 .toast-icon {
120 font-size: 1.2rem;
121 line-height: 1;
123 - opacity: 0.9;
122 min-width: 24px;
123 text-align: center;
124 margin-top: 2px;
@@ -155,7 +153,7 @@
153
154 /* Toast Dismiss */
155 .toast-dismiss {
158 - background: rgba(255, 255, 255, 0.1);
156 + background: transparent; /* No background by default */
157 border: none;
158 border-radius: 4px;
159 color: var(--color-text);
@@ -163,16 +161,12 @@
161 padding: 4px 6px;
162 font-size: 0.8rem;
163 transition: all 0.2s ease;
166 - opacity: 0;
167 - }
168 -
169 - .toast-item:hover .toast-dismiss {
170 - opacity: 1;
164 + opacity: 1; /* Always visible */
165 }
166
167 .toast-dismiss:hover {
174 - background: rgba(255, 255, 255, 0.2);
175 - color: #fff;
168 + background: var(--color-panel);
169 + color: var(--color-primary);
170 }
171
172 /* Toast Animations */
@@ -188,25 +182,6 @@
182 transform: translateX(100%) scale(0.8);
183 }
184
191 - /* Light Mode Styles */
192 - .light-mode .toast-item {
193 - background: rgba(255, 255, 255, 0.95);
194 - border-color: rgba(0, 0, 0, 0.1);
195 - color: var(--color-text);
196 - }
197 -
198 - .light-mode .toast-item:hover {
199 - background: rgba(255, 255, 255, 0.98);
200 - border-color: rgba(0, 0, 0, 0.2);
201 - }
202 -
203 - .light-mode .toast-dismiss {
204 - background: rgba(0, 0, 0, 0.1);
205 - }
206 -
207 - .light-mode .toast-dismiss:hover {
208 - background: rgba(0, 0, 0, 0.2);
209 - }
185
186 /* Mobile Responsive */
187 @media (max-width: 768px) {
webui/components/settings/backup/backup-store.js
+3 -3
@@ -1,9 +1,9 @@
1 import { createStore } from "/js/AlpineStore.js";
2
3 // Global function references
4 -const sendJsonData = window.sendJsonData;
5 -const toast = window.toast;
6 -const fetchApi = window.fetchApi;
4 +const sendJsonData = globalThis.sendJsonData;
5 +const toast = globalThis.toast;
6 +const fetchApi = globalThis.fetchApi;
7
8 // ⚠️ CRITICAL: The .env file contains API keys and essential configuration.
9 // This file is REQUIRED for Agent Zero to function and must be backed up.
webui/index.css
+16 -16
@@ -68,6 +68,21 @@
68 hue-rotate(177deg) brightness(87%) contrast(85%);
69 }
70
71 +/* Light mode class */
72 +.light-mode {
73 + --color-background: var(--color-background-light);
74 + --color-text: var(--color-text-light);
75 + --color-primary: var(--color-primary-light);
76 + --color-secondary: var(--color-secondary-light);
77 + --color-accent: var(--color-accent-light);
78 + --color-message-bg: var(--color-message-bg-light);
79 + --color-message-text: var(--color-message-text-light);
80 + --color-panel: var(--color-panel-light);
81 + --color-border: var(--color-border-light);
82 + --color-input: var(--color-input-light);
83 + --color-input-focus: var(--color-input-focus-light);
84 +}
85 +
86 /* Reset and Base Styles */
87 body,
88 html {
@@ -402,6 +417,7 @@ img {
417 }
418
419 #time-date-container {
420 + z-index: 1000;
421 position: fixed;
422 right: var(--spacing-md);
423 display: flex;
@@ -1687,22 +1703,6 @@ a:active {
1703 color: inherit;
1704 }
1705
1690 -/* Light mode class */
1691 -.light-mode {
1692 - --color-background: var(--color-background-light);
1693 - --color-text: var(--color-text-light);
1694 - --color-primary: var(--color-primary-light);
1695 - --color-secondary: var(--color-secondary-light);
1696 - --color-accent: var(--color-accent-light);
1697 - --color-message-bg: var(--color-message-bg-light);
1698 - --color-message-text: var(--color-message-text-light);
1699 - --color-panel: var(--color-panel-light);
1700 - --color-border: var(--color-border-light);
1701 - --color-input: var(--color-input-light);
1702 - --color-input-focus: var(--color-input-focus-light);
1703 -}
1704 -
1705 -
1706
1707 .light-mode .connected {
1708 color: #4caf50;
webui/index.html
+12 -10
@@ -27,7 +27,7 @@
27 <script src="vendor/flatpickr/flatpickr.min.js"></script>
28
29 <script>
30 - window.safeCall = function (name, ...args) {
30 + globalThis.safeCall = function (name, ...args) {
31 if (window[name]) window[name](...args)
32 }
33 </script>
@@ -35,7 +35,7 @@
35 <!-- Pre-initialize schedulerSettings to ensure Alpine doesn't miss it -->
36 <script>
37 // Pre-define schedulerSettings skeleton to ensure it's available to Alpine
38 - window.schedulerSettings = function() {
38 + globalThis.schedulerSettings = function() {
39 return {
40 tasks: [],
41 isLoading: true,
@@ -214,7 +214,7 @@
214 tasks: [],
215 selected: '',
216 openTaskDetail(taskId) {
217 - window.openTaskDetail(taskId);
217 + globalThis.openTaskDetail(taskId);
218 }
219 }"
220 style="display: none;">
@@ -285,7 +285,7 @@
285 <span>Autoscroll</span>
286 <label class="switch">
287 <input id="auto-scroll-switch" type="checkbox" x-model="autoScroll"
288 - x-effect="window.safeCall('toggleAutoScroll',autoScroll)">
288 + x-effect="globalThis.safeCall('toggleAutoScroll',autoScroll)">
289 <span class="slider"></span>
290 </label>
291 </li>
@@ -309,7 +309,7 @@
309 <span>Show thoughts</span>
310 <label class="switch">
311 <input type="checkbox" x-model="showThoughts"
312 - x-effect="window.safeCall('toggleThoughts',showThoughts)">
312 + x-effect="globalThis.safeCall('toggleThoughts',showThoughts)">
313 <span class="slider"></span>
314 </label>
315 </li>
@@ -317,7 +317,7 @@
317 <span>Show JSON</span>
318 <label class="switch">
319 <input type="checkbox" x-model="showJson"
320 - x-effect="window.safeCall('toggleJson',showJson)">
320 + x-effect="globalThis.safeCall('toggleJson',showJson)">
321 <span class="slider"></span>
322 </label>
323 </li>
@@ -325,7 +325,7 @@
325 <span>Show utility messages</span>
326 <label class="switch">
327 <input type="checkbox" x-model="showUtils"
328 - x-effect="window.safeCall('toggleUtils',showUtils)">
328 + x-effect="globalThis.safeCall('toggleUtils',showUtils)">
329 <span class="slider"></span>
330 </label>
331 </li>
@@ -360,7 +360,9 @@
360 </div>
361
362 <!-- NEW: Toast Stack Component -->
363 - <x-component path="notifications/notification-toast-stack.html"></x-component>
363 + <div style="position: relative; height: 0;">
364 + <x-component path="notifications/notification-toast-stack.html"></x-component>
365 + </div>
366
367 <div id="toast" class="toast">
368 <div class="toast__content">
@@ -485,7 +487,7 @@
487 <p>Files</p>
488 </button>
489
488 - <button class="text-button" id="history_inspect" @click="window.openHistoryModal()">
490 + <button class="text-button" id="history_inspect" @click="globalThis.openHistoryModal()">
491 <svg xmlns="http://www.w3.org/2000/svg" viewBox="5 10 85 85">
492 <path fill="currentColor"
493 d="m59.572,57.949c-.41,0-.826-.105-1.207-.325l-9.574-5.528c-.749-.432-1.21-1.231-1.21-2.095v-14.923c0-1.336,1.083-2.419,2.419-2.419s2.419,1.083,2.419,2.419v13.526l8.364,4.829c1.157.668,1.554,2.148.886,3.305-.448.776-1.261,1.21-2.097,1.21Zm30.427-7.947c0,10.684-4.161,20.728-11.716,28.283-6.593,6.59-15.325,10.69-24.59,11.544-1.223.113-2.448.169-3.669.169-7.492,0-14.878-2.102-21.22-6.068l-15.356,5.733c-.888.331-1.887.114-2.557-.556s-.887-1.669-.556-2.557l5.733-15.351c-4.613-7.377-6.704-16.165-5.899-24.891.854-9.266,4.954-17.998,11.544-24.588,7.555-7.555,17.6-11.716,28.285-11.716s20.73,4.161,28.285,11.716c7.555,7.555,11.716,17.599,11.716,28.283Zm-15.137-24.861c-13.71-13.71-36.018-13.71-49.728,0-11.846,11.846-13.682,30.526-4.365,44.417.434.647.53,1.464.257,2.194l-4.303,11.523,11.528-4.304c.274-.102.561-.153.846-.153.474,0,.944.139,1.348.41,13.888,9.315,32.568,7.479,44.417-4.365,13.707-13.708,13.706-36.014,0-49.723Zm-24.861-4.13c-15.989,0-28.996,13.006-28.996,28.992s13.008,28.992,28.996,28.992c1.336,0,2.419-1.083,2.419-2.419s-1.083-2.419-2.419-2.419c-13.32,0-24.157-10.835-24.157-24.153s10.837-24.153,24.157-24.153,24.153,10.835,24.153,24.153c0,1.336,1.083,2.419,2.419,2.419s2.419-1.083,2.419-2.419c0-15.986-13.006-28.992-28.992-28.992Zm25.041,33.531c-1.294.347-2.057,1.673-1.71,2.963.343,1.289,1.669,2.057,2.963,1.71,1.289-.343,2.053-1.669,1.71-2.963-.347-1.289-1.673-2.057-2.963-1.71Zm-2.03,6.328c-1.335,0-2.419,1.084-2.419,2.419s1.084,2.419,2.419,2.419,2.419-1.084,2.419-2.419-1.084-2.419-2.419-2.419Zm-3.598,5.587c-1.289-.347-2.615.416-2.963,1.71-.343,1.289.421,2.615,1.71,2.963,1.294.347,2.62-.421,2.963-1.71.347-1.294-.416-2.62-1.71-2.963Zm-4.919,4.462c-1.157-.667-2.638-.27-3.306.887-.667,1.157-.27,2.638.887,3.305,1.157.668,2.638.27,3.306-.887.667-1.157.27-2.638-.887-3.306Zm-9.327,3.04c-.946.946-.946,2.478,0,3.42.942.946,2.473.946,3.42,0,.946-.942.946-2.473,0-3.42-.946-.946-2.478-.946-3.42,0Z">
@@ -494,7 +496,7 @@
496 <p>History</p>
497 </button>
498
497 - <button class="text-button" id="ctx_window" @click="window.openCtxWindowModal()">
499 + <button class="text-button" id="ctx_window" @click="globalThis.openCtxWindowModal()">
500 <svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="17 15 70 70" fill="currentColor">
501 <path
502 d="m63 25c1.1016 0 2-0.89844 2-2s-0.89844-2-2-2h-26c-1.1016 0-2 0.89844-2 2s0.89844 2 2 2z">
webui/index.js
+39 -38
@@ -4,8 +4,9 @@ import * as css from "/js/css.js";
4 import { sleep } from "/js/sleep.js";
5 import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
6 import { store as speechStore } from "/components/chat/speech/speech-store.js";
7 +import { store as notificationStore } from "/components/notifications/notification-store.js";
8
8 -window.fetchApi = api.fetchApi; // TODO - backward compatibility for non-modular scripts, remove once refactored to alpine
9 +globalThis.fetchApi = api.fetchApi; // TODO - backward compatibility for non-modular scripts, remove once refactored to alpine
10
11 const leftPanel = document.getElementById("left-panel");
12 const rightPanel = document.getElementById("right-panel");
@@ -69,8 +70,8 @@ function handleResize() {
70 }
71 }
72
72 -window.addEventListener("load", handleResize);
73 -window.addEventListener("resize", handleResize);
73 +globalThis.addEventListener("load", handleResize);
74 +globalThis.addEventListener("resize", handleResize);
75
76 document.addEventListener("DOMContentLoaded", () => {
77 const overlay = document.getElementById("sidebar-overlay");
@@ -184,7 +185,7 @@ function toastFetchError(text, error) {
185 ).catch((e) => console.error("Failed to show connection error toast:", e));
186 }
187 }
187 -window.toastFetchError = toastFetchError;
188 +globalThis.toastFetchError = toastFetchError;
189
190 chatInput.addEventListener("keydown", (e) => {
191 if (e.key === "Enter" && !e.shiftKey) {
@@ -241,7 +242,7 @@ function setMessage(id, type, heading, content, temp, kvps = null) {
242 return result;
243 }
244
244 -window.loadKnowledge = async function () {
245 +globalThis.loadKnowledge = async function () {
246 const input = document.createElement("input");
247 input.type = "file";
248 input.accept = ".txt,.pdf,.csv,.html,.json,.md";
@@ -300,7 +301,7 @@ export const sendJsonData = async function (url, data) {
301 // const jsonResponse = await response.json();
302 // return jsonResponse;
303 };
303 -window.sendJsonData = sendJsonData;
304 +globalThis.sendJsonData = sendJsonData;
305
306 function generateGUID() {
307 return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
@@ -316,7 +317,7 @@ function getConnectionStatus() {
317
318 function setConnectionStatus(connected) {
319 connectionStatus = connected;
319 - if (window.Alpine && timeDate) {
320 + if (globalThis.Alpine && timeDate) {
321 const statusIconEl = timeDate.querySelector(".status-icon");
322 if (statusIconEl) {
323 const statusIcon = Alpine.$data(statusIconEl);
@@ -388,7 +389,7 @@ async function poll() {
389 notificationStore.updateFromPoll(response);
390
391 //set ui model vars from backend
391 - if (window.Alpine && inputSection) {
392 + if (globalThis.Alpine && inputSection) {
393 const inputAD = Alpine.$data(inputSection);
394 if (inputAD) {
395 inputAD.paused = response.paused;
@@ -401,7 +402,7 @@ async function poll() {
402 // Update chats list and sort by created_at time (newer first)
403 let chatsAD = null;
404 let contexts = response.contexts || [];
404 - if (window.Alpine && chatsSection) {
405 + if (globalThis.Alpine && chatsSection) {
406 chatsAD = Alpine.$data(chatsSection);
407 if (chatsAD) {
408 chatsAD.contexts = contexts.sort(
@@ -412,7 +413,7 @@ async function poll() {
413
414 // Update tasks list and sort by creation time (newer first)
415 const tasksSection = document.getElementById("tasks-section");
415 - if (window.Alpine && tasksSection) {
416 + if (globalThis.Alpine && tasksSection) {
417 const tasksAD = Alpine.$data(tasksSection);
418 if (tasksAD) {
419 let tasks = response.tasks || [];
@@ -572,15 +573,15 @@ function updateProgress(progress, active) {
573 }
574 }
575
575 -window.pauseAgent = async function (paused) {
576 +globalThis.pauseAgent = async function (paused) {
577 try {
578 const resp = await sendJsonData("/pause", { paused: paused, context });
579 } catch (e) {
579 - window.toastFetchError("Error pausing agent", e);
580 + globalThis.toastFetchError("Error pausing agent", e);
581 }
582 };
583
583 -window.resetChat = async function (ctxid = null) {
584 +globalThis.resetChat = async function (ctxid = null) {
585 try {
586 const resp = await sendJsonData("/chat_reset", {
587 context: ctxid === null ? context : ctxid,
@@ -588,20 +589,20 @@ window.resetChat = async function (ctxid = null) {
589 resetCounter++;
590 if (ctxid === null) updateAfterScroll();
591 } catch (e) {
591 - window.toastFetchError("Error resetting chat", e);
592 + globalThis.toastFetchError("Error resetting chat", e);
593 }
594 };
595
595 -window.newChat = async function () {
596 +globalThis.newChat = async function () {
597 try {
598 setContext(generateGUID());
599 updateAfterScroll();
600 } catch (e) {
600 - window.toastFetchError("Error creating new chat", e);
601 + globalThis.toastFetchError("Error creating new chat", e);
602 }
603 };
604
604 -window.killChat = async function (id) {
605 +globalThis.killChat = async function (id) {
606 if (!id) {
607 console.error("No chat ID provided for deletion");
608 return;
@@ -638,7 +639,7 @@ window.killChat = async function (id) {
639 toast("Chat deleted successfully", "success");
640 } catch (e) {
641 console.error("Error deleting chat:", e);
641 - window.toastFetchError("Error deleting chat", e);
642 + globalThis.toastFetchError("Error deleting chat", e);
643 }
644 };
645
@@ -700,7 +701,7 @@ function ensureProperTabSelection(contextId) {
701 return false;
702 }
703
703 -window.selectChat = async function (id) {
704 +globalThis.selectChat = async function (id) {
705 if (id === context) return; //already selected
706
707 // Check if we need to switch tabs based on the context type
@@ -751,7 +752,7 @@ export const setContext = function (id) {
752 chatHistory.innerHTML = "";
753
754 // Update both selected states
754 - if (window.Alpine) {
755 + if (globalThis.Alpine) {
756 if (chatsSection) {
757 const chatsAD = Alpine.$data(chatsSection);
758 if (chatsAD) chatsAD.selected = id;
@@ -774,15 +775,15 @@ export const getChatBasedId = function (id) {
775 return context + "-" + resetCounter + "-" + id;
776 };
777
777 -window.toggleAutoScroll = async function (_autoScroll) {
778 +globalThis.toggleAutoScroll = async function (_autoScroll) {
779 autoScroll = _autoScroll;
780 };
781
781 -window.toggleJson = async function (showJson) {
782 +globalThis.toggleJson = async function (showJson) {
783 css.toggleCssProperty(".msg-json", "display", showJson ? "block" : "none");
784 };
785
785 -window.toggleThoughts = async function (showThoughts) {
786 +globalThis.toggleThoughts = async function (showThoughts) {
787 css.toggleCssProperty(
788 ".msg-thoughts",
789 "display",
@@ -790,7 +791,7 @@ window.toggleThoughts = async function (showThoughts) {
791 );
792 };
793
793 -window.toggleUtils = async function (showUtils) {
794 +globalThis.toggleUtils = async function (showUtils) {
795 css.toggleCssProperty(
796 ".message-util",
797 "display",
@@ -798,7 +799,7 @@ window.toggleUtils = async function (showUtils) {
799 );
800 };
801
801 -window.toggleDarkMode = function (isDark) {
802 +globalThis.toggleDarkMode = function (isDark) {
803 if (isDark) {
804 document.body.classList.remove("light-mode");
805 document.body.classList.add("dark-mode");
@@ -810,13 +811,13 @@ window.toggleDarkMode = function (isDark) {
811 localStorage.setItem("darkMode", isDark);
812 };
813
813 -window.toggleSpeech = function (isOn) {
814 +globalThis.toggleSpeech = function (isOn) {
815 console.log("Speech:", isOn);
816 localStorage.setItem("speech", isOn);
817 if (!isOn) speechStore.stopAudio();
818 };
819
819 -window.nudge = async function () {
820 +globalThis.nudge = async function () {
821 try {
822 const resp = await sendJsonData("/nudge", { ctxid: getContext() });
823 } catch (e) {
@@ -824,7 +825,7 @@ window.nudge = async function () {
825 }
826 };
827
827 -window.restart = async function () {
828 +globalThis.restart = async function () {
829 try {
830 if (!getConnectionStatus()) {
831 await toastFrontendError(
@@ -872,7 +873,7 @@ document.addEventListener("DOMContentLoaded", () => {
873 toggleDarkMode(isDarkMode);
874 });
875
875 -window.loadChats = async function () {
876 +globalThis.loadChats = async function () {
877 try {
878 const fileContents = await readJsonFiles();
879 const response = await sendJsonData("/chat_load", { chats: fileContents });
@@ -896,7 +897,7 @@ window.loadChats = async function () {
897 }
898 };
899
899 -window.saveChat = async function () {
900 +globalThis.saveChat = async function () {
901 try {
902 const response = await sendJsonData("/chat_export", { ctxid: context });
903
@@ -1007,12 +1008,12 @@ function toast(text, type = "info", timeout = 5000) {
1008 }
1009
1010 }
1010 -window.toast = toast;
1011 +globalThis.toast = toast;
1012
1013 // OLD: hideToast function removed - now using new notification system
1014
1015 function scrollChanged(isAtBottom) {
1015 - if (window.Alpine && autoScrollSwitch) {
1016 + if (globalThis.Alpine && autoScrollSwitch) {
1017 const inputAS = Alpine.$data(autoScrollSwitch);
1018 if (inputAS) {
1019 inputAS.autoScroll = isAtBottom;
@@ -1124,7 +1125,7 @@ function activateTab(tabName) {
1125 chatsSection.style.display = "";
1126
1127 // Get the available contexts from Alpine.js data
1127 - const chatsAD = window.Alpine ? Alpine.$data(chatsSection) : null;
1128 + const chatsAD = globalThis.Alpine ? Alpine.$data(chatsSection) : null;
1129 const availableContexts = chatsAD?.contexts || [];
1130
1131 // Restore previous chat selection
@@ -1148,7 +1149,7 @@ function activateTab(tabName) {
1149 tasksSection.style.flexDirection = "column";
1150
1151 // Get the available tasks from Alpine.js data
1151 - const tasksAD = window.Alpine ? Alpine.$data(tasksSection) : null;
1152 + const tasksAD = globalThis.Alpine ? Alpine.$data(tasksSection) : null;
1153 const availableTasks = tasksAD?.tasks || [];
1154
1155 // Restore previous task selection
@@ -1200,7 +1201,7 @@ function initializeActiveTab() {
1201 // Open the scheduler detail view for a specific task
1202 function openTaskDetail(taskId) {
1203 // Wait for Alpine.js to be fully loaded
1203 - if (window.Alpine) {
1204 + if (globalThis.Alpine) {
1205 // Get the settings modal button and click it to ensure all init logic happens
1206 const settingsButton = document.getElementById("settings");
1207 if (settingsButton) {
@@ -1215,7 +1216,7 @@ function openTaskDetail(taskId) {
1216 }
1217
1218 // Get the Alpine.js data for the modal
1218 - const modalData = window.Alpine ? Alpine.$data(modalEl) : null;
1219 + const modalData = globalThis.Alpine ? Alpine.$data(modalEl) : null;
1220
1221 // Use a timeout to ensure the modal is fully rendered
1222 setTimeout(() => {
@@ -1234,7 +1235,7 @@ function openTaskDetail(taskId) {
1235 }
1236
1237 // Get the Alpine.js data for the scheduler component
1237 - const schedulerData = window.Alpine
1238 + const schedulerData = globalThis.Alpine
1239 ? Alpine.$data(schedulerComponent)
1240 : null;
1241
@@ -1253,4 +1254,4 @@ function openTaskDetail(taskId) {
1254 }
1255
1256 // Make the function available globally
1256 -window.openTaskDetail = openTaskDetail;
1257 +globalThis.openTaskDetail = openTaskDetail;