diff --git a/.gitignore b/.gitignore index 0a1223f1d45d..a7914c6247f3 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ log-ingestion.txt /logs *.log *.mp3 +!autogpt_platform/frontend/public/notification.mp3 mem.sqlite3 venvAutoGPT diff --git a/autogpt_platform/frontend/public/notification.mp3 b/autogpt_platform/frontend/public/notification.mp3 new file mode 100644 index 000000000000..b3a0dabf91db Binary files /dev/null and b/autogpt_platform/frontend/public/notification.mp3 differ diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatSidebar/ChatSidebar.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatSidebar/ChatSidebar.tsx index 6284c60a4668..c135c95aa76e 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatSidebar/ChatSidebar.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatSidebar/ChatSidebar.tsx @@ -34,6 +34,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { AnimatePresence, motion } from "framer-motion"; import { parseAsString, useQueryState } from "nuqs"; import { useEffect, useRef, useState } from "react"; +import { formatNotificationTitle } from "../../helpers"; import { useCopilotUIStore } from "../../store"; import { NotificationToggle } from "./components/NotificationToggle/NotificationToggle"; import { DeleteChatDialog } from "../DeleteChatDialog/DeleteChatDialog"; @@ -123,9 +124,8 @@ export function ChatSidebar() { useEffect(() => { if (!sessionId || !completedSessionIDs.has(sessionId)) return; clearCompletedSession(sessionId); - const remaining = completedSessionIDs.size - 1; - document.title = - remaining > 0 ? `(${remaining}) Otto is ready - AutoGPT` : "AutoGPT"; + const remaining = Math.max(0, completedSessionIDs.size - 1); + document.title = formatNotificationTitle(remaining); }, [sessionId, completedSessionIDs, clearCompletedSession]); const sessions = diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/NotificationBanner/NotificationBanner.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/NotificationBanner/NotificationBanner.tsx index 15602f68fb54..b8217024a1eb 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/NotificationBanner/NotificationBanner.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/NotificationBanner/NotificationBanner.tsx @@ -56,8 +56,8 @@ export function NotificationBanner() {
- Enable browser notifications to know when Otto finishes working, even - when you switch tabs. + Enable browser notifications to know when AutoPilot finishes working, + even when you switch tabs.
- Otto can notify you when a response is ready, even if you switch - tabs or close this page. Enable notifications so you never miss one. + AutoPilot can notify you when a response is ready, even if you + switch tabs or close this page. Enable notifications so you never + miss one. - + diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.test.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.test.ts new file mode 100644 index 000000000000..d56dbd13d8a6 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + ORIGINAL_TITLE, + formatNotificationTitle, + parseSessionIDs, +} from "./helpers"; + +describe("formatNotificationTitle", () => { + it("returns base title when count is 0", () => { + expect(formatNotificationTitle(0)).toBe(ORIGINAL_TITLE); + }); + + it("returns formatted title with count", () => { + expect(formatNotificationTitle(3)).toBe( + `(3) AutoPilot is ready - ${ORIGINAL_TITLE}`, + ); + }); + + it("returns base title for negative count", () => { + expect(formatNotificationTitle(-1)).toBe(ORIGINAL_TITLE); + }); + + it("returns base title for NaN", () => { + expect(formatNotificationTitle(NaN)).toBe(ORIGINAL_TITLE); + }); + + it("returns formatted title for count of 1", () => { + expect(formatNotificationTitle(1)).toBe( + `(1) AutoPilot is ready - ${ORIGINAL_TITLE}`, + ); + }); +}); + +describe("parseSessionIDs", () => { + it("returns empty set for null", () => { + expect(parseSessionIDs(null)).toEqual(new Set()); + }); + + it("returns empty set for undefined", () => { + expect(parseSessionIDs(undefined)).toEqual(new Set()); + }); + + it("returns empty set for empty string", () => { + expect(parseSessionIDs("")).toEqual(new Set()); + }); + + it("parses valid JSON array of strings", () => { + expect(parseSessionIDs('["a","b","c"]')).toEqual(new Set(["a", "b", "c"])); + }); + + it("filters out non-string elements", () => { + expect(parseSessionIDs('[1,"valid",null,true,"also-valid"]')).toEqual( + new Set(["valid", "also-valid"]), + ); + }); + + it("returns empty set for non-array JSON", () => { + expect(parseSessionIDs('{"key":"value"}')).toEqual(new Set()); + }); + + it("returns empty set for JSON string value", () => { + expect(parseSessionIDs('"oops"')).toEqual(new Set()); + }); + + it("returns empty set for JSON number value", () => { + expect(parseSessionIDs("42")).toEqual(new Set()); + }); + + it("returns empty set for malformed JSON", () => { + expect(parseSessionIDs("{broken")).toEqual(new Set()); + }); + + it("deduplicates entries", () => { + expect(parseSessionIDs('["a","a","b"]')).toEqual(new Set(["a", "b"])); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts index 3c64390deb58..ea5ceee77ef0 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts @@ -1,5 +1,33 @@ import type { UIMessage } from "ai"; +export const ORIGINAL_TITLE = "AutoGPT"; + +/** + * Build the document title showing how many sessions are ready. + * Returns the base title when count is 0. + */ +export function formatNotificationTitle(count: number): string { + return count > 0 + ? `(${count}) AutoPilot is ready - ${ORIGINAL_TITLE}` + : ORIGINAL_TITLE; +} + +/** + * Safely parse a JSON string (from localStorage) into a `Set` of + * session IDs. Returns an empty set for `null`, malformed, or non-array values. + */ +export function parseSessionIDs(raw: string | null | undefined): Set { + if (!raw) return new Set(); + try { + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) + ? new Set(parsed.filter((v) => typeof v === "string")) + : new Set(); + } catch { + return new Set(); + } +} + /** * Check whether a refetchSession result indicates the backend still has an * active SSE stream for this session. diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/store.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/store.ts index 7d8aeccde422..742aadf7b762 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/store.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/store.ts @@ -1,11 +1,27 @@ import { Key, storage } from "@/services/storage/local-storage"; import { create } from "zustand"; +import { ORIGINAL_TITLE, parseSessionIDs } from "./helpers"; export interface DeleteTarget { id: string; title: string | null | undefined; } +const isClient = typeof window !== "undefined"; + +function persistCompletedSessions(ids: Set) { + if (!isClient) return; + try { + if (ids.size === 0) { + storage.clean(Key.COPILOT_COMPLETED_SESSIONS); + } else { + storage.set(Key.COPILOT_COMPLETED_SESSIONS, JSON.stringify([...ids])); + } + } catch { + // Keep in-memory state authoritative if persistence is unavailable + } +} + interface CopilotUIState { /** Prompt extracted from URL hash (e.g. /copilot#prompt=...) for input prefill. */ initialPrompt: string | null; @@ -44,23 +60,30 @@ export const useCopilotUIStore = create((set) => ({ isDrawerOpen: false, setDrawerOpen: (open) => set({ isDrawerOpen: open }), - completedSessionIDs: new Set(), + completedSessionIDs: isClient + ? parseSessionIDs(storage.get(Key.COPILOT_COMPLETED_SESSIONS)) + : new Set(), addCompletedSession: (id) => set((state) => { const next = new Set(state.completedSessionIDs); next.add(id); + persistCompletedSessions(next); return { completedSessionIDs: next }; }), clearCompletedSession: (id) => set((state) => { const next = new Set(state.completedSessionIDs); next.delete(id); + persistCompletedSessions(next); return { completedSessionIDs: next }; }), - clearAllCompletedSessions: () => - set({ completedSessionIDs: new Set() }), + clearAllCompletedSessions: () => { + persistCompletedSessions(new Set()); + set({ completedSessionIDs: new Set() }); + }, isNotificationsEnabled: + isClient && storage.get(Key.COPILOT_NOTIFICATIONS_ENABLED) === "true" && typeof Notification !== "undefined" && Notification.permission === "granted", @@ -69,7 +92,8 @@ export const useCopilotUIStore = create((set) => ({ set({ isNotificationsEnabled: enabled }); }, - isSoundEnabled: storage.get(Key.COPILOT_SOUND_ENABLED) !== "false", + isSoundEnabled: + !isClient || storage.get(Key.COPILOT_SOUND_ENABLED) !== "false", toggleSound: () => set((state) => { const next = !state.isSoundEnabled; @@ -85,11 +109,14 @@ export const useCopilotUIStore = create((set) => ({ storage.clean(Key.COPILOT_SOUND_ENABLED); storage.clean(Key.COPILOT_NOTIFICATION_BANNER_DISMISSED); storage.clean(Key.COPILOT_NOTIFICATION_DIALOG_DISMISSED); + storage.clean(Key.COPILOT_COMPLETED_SESSIONS); set({ completedSessionIDs: new Set(), isNotificationsEnabled: false, isSoundEnabled: true, }); - document.title = "AutoGPT"; + if (isClient) { + document.title = ORIGINAL_TITLE; + } }, })); diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotNotifications.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotNotifications.ts index 6e721b2183bd..bfd5ce3998eb 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotNotifications.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotNotifications.ts @@ -1,10 +1,42 @@ +import { getGetV2ListSessionsQueryKey } from "@/app/api/__generated__/endpoints/chat/chat"; import { useBackendAPI } from "@/lib/autogpt-server-api/context"; import type { WebSocketNotification } from "@/lib/autogpt-server-api/types"; +import { Key } from "@/services/storage/local-storage"; +import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef } from "react"; +import { + ORIGINAL_TITLE, + formatNotificationTitle, + parseSessionIDs, +} from "./helpers"; import { useCopilotUIStore } from "./store"; -const ORIGINAL_TITLE = "AutoGPT"; -const NOTIFICATION_SOUND_PATH = "/sounds/notification.mp3"; +const NOTIFICATION_SOUND_PATH = "/notification.mp3"; + +/** + * Show a browser notification with click-to-navigate behaviour. + * Wrapped in try-catch so it degrades gracefully in service-worker or + * other restricted contexts where the Notification constructor throws. + */ +function showBrowserNotification( + title: string, + opts: { body: string; icon: string; sessionID: string }, +) { + try { + const n = new Notification(title, { body: opts.body, icon: opts.icon }); + n.onclick = () => { + window.focus(); + const url = new URL(window.location.href); + url.searchParams.set("sessionId", opts.sessionID); + window.history.pushState({}, "", url.toString()); + window.dispatchEvent(new PopStateEvent("popstate")); + n.close(); + }; + } catch { + // Notification constructor is unavailable (e.g. service-worker context). + // The user will still see the in-app badge and title update. + } +} /** * Listens for copilot completion notifications via WebSocket. @@ -12,17 +44,23 @@ const NOTIFICATION_SOUND_PATH = "/sounds/notification.mp3"; */ export function useCopilotNotifications(activeSessionID: string | null) { const api = useBackendAPI(); + const queryClient = useQueryClient(); const audioRef = useRef(null); const activeSessionRef = useRef(activeSessionID); activeSessionRef.current = activeSessionID; const windowFocusedRef = useRef(true); - // Pre-load audio element + // Pre-load audio element and sync document title with persisted state useEffect(() => { if (typeof window === "undefined") return; const audio = new Audio(NOTIFICATION_SOUND_PATH); audio.volume = 0.5; audioRef.current = audio; + + const count = useCopilotUIStore.getState().completedSessionIDs.size; + if (count > 0) { + document.title = formatNotificationTitle(count); + } }, []); // Listen for WebSocket notifications @@ -49,7 +87,7 @@ export function useCopilotNotifications(activeSessionID: string | null) { // Always update UI state (checkmark + title) regardless of notification setting state.addCompletedSession(sessionID); const count = useCopilotUIStore.getState().completedSessionIDs.size; - document.title = `(${count}) Otto is ready - ${ORIGINAL_TITLE}`; + document.title = formatNotificationTitle(count); // Sound and browser notifications are gated by the user setting if (!state.isNotificationsEnabled) return; @@ -65,18 +103,11 @@ export function useCopilotNotifications(activeSessionID: string | null) { Notification.permission === "granted" && isUserAway ) { - const n = new Notification("Otto is ready", { + showBrowserNotification("AutoPilot is ready", { body: "A response is waiting for you.", icon: "/favicon.ico", + sessionID, }); - n.onclick = () => { - window.focus(); - const url = new URL(window.location.href); - url.searchParams.set("sessionId", sessionID); - window.history.pushState({}, "", url.toString()); - window.dispatchEvent(new PopStateEvent("popstate")); - n.close(); - }; } } @@ -115,4 +146,24 @@ export function useCopilotNotifications(activeSessionID: string | null) { document.removeEventListener("visibilitychange", handleVisibilityChange); }; }, []); + + // Sync completedSessionIDs across tabs via localStorage storage events + useEffect(() => { + function handleStorage(e: StorageEvent) { + if (e.key !== Key.COPILOT_COMPLETED_SESSIONS) return; + // localStorage is the shared source of truth — adopt it directly so both + // additions (new completions) and removals (cleared sessions) propagate. + const next = parseSessionIDs(e.newValue); + useCopilotUIStore.setState({ completedSessionIDs: next }); + document.title = formatNotificationTitle(next.size); + + // Refetch the session list so the sidebar reflects the latest + // is_processing state (avoids stale spinner after cross-tab clear). + queryClient.invalidateQueries({ + queryKey: getGetV2ListSessionsQueryKey(), + }); + } + window.addEventListener("storage", handleStorage); + return () => window.removeEventListener("storage", handleStorage); + }, [queryClient]); } diff --git a/autogpt_platform/frontend/src/components/layout/Navbar/components/AgentActivityDropdown/AgentActivityDropdown.tsx b/autogpt_platform/frontend/src/components/layout/Navbar/components/AgentActivityDropdown/AgentActivityDropdown.tsx index 1d120c3b09a5..3c792d32397f 100644 --- a/autogpt_platform/frontend/src/components/layout/Navbar/components/AgentActivityDropdown/AgentActivityDropdown.tsx +++ b/autogpt_platform/frontend/src/components/layout/Navbar/components/AgentActivityDropdown/AgentActivityDropdown.tsx @@ -6,7 +6,7 @@ import { PopoverContent, PopoverTrigger, } from "@/components/__legacy__/ui/popover"; -import { Bell } from "@phosphor-icons/react"; +import { Pulse } from "@phosphor-icons/react"; import { ActivityDropdown } from "./components/ActivityDropdown/ActivityDropdown"; import { formatNotificationCount } from "./helpers"; import { useAgentActivityDropdown } from "./useAgentActivityDropdown"; @@ -30,7 +30,7 @@ export function AgentActivityDropdown() { data-testid="agent-activity-button" aria-label="View Agent Activity" > - + {activeCount > 0 && ( <> diff --git a/autogpt_platform/frontend/src/components/molecules/Dialog/components/BaseFooter.tsx b/autogpt_platform/frontend/src/components/molecules/Dialog/components/BaseFooter.tsx index 1951525ccd89..426473e6f79c 100644 --- a/autogpt_platform/frontend/src/components/molecules/Dialog/components/BaseFooter.tsx +++ b/autogpt_platform/frontend/src/components/molecules/Dialog/components/BaseFooter.tsx @@ -1,3 +1,4 @@ +import { cn } from "@/lib/utils"; import { useDialogCtx } from "../useDialogCtx"; interface Props { @@ -10,14 +11,14 @@ interface Props { export function BaseFooter({ children, testId = "modal-footer", - className = "", + className, style, }: Props) { const ctx = useDialogCtx(); return ctx.isLargeScreen ? (
@@ -25,7 +26,7 @@ export function BaseFooter({
) : (
{children} diff --git a/autogpt_platform/frontend/src/services/storage/local-storage.ts b/autogpt_platform/frontend/src/services/storage/local-storage.ts index 6b20b68a20b3..5f85b5196ec4 100644 --- a/autogpt_platform/frontend/src/services/storage/local-storage.ts +++ b/autogpt_platform/frontend/src/services/storage/local-storage.ts @@ -15,6 +15,7 @@ export enum Key { COPILOT_NOTIFICATIONS_ENABLED = "copilot-notifications-enabled", COPILOT_NOTIFICATION_BANNER_DISMISSED = "copilot-notification-banner-dismissed", COPILOT_NOTIFICATION_DIALOG_DISMISSED = "copilot-notification-dialog-dismissed", + COPILOT_COMPLETED_SESSIONS = "copilot-completed-sessions", } function get(key: Key) {