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 ? (