mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
fix(frontend): AutoPilot notification follow-ups — branding, UX, persistence, and cross-tab sync (#12428)
AutoPilot (copilot) notifications had several follow-up issues after initial implementation: old "Otto" branding, UX quirks, a service-worker crash, notification state that didn't persist or sync across tabs, a broken notification sound, and noisy Sentry alerts from SSR. ### Changes 🏗️ - **Rename "Otto" → "AutoPilot"** in all notification surfaces: browser notifications, document title badge, permission dialog copy, and notification banner copy - **Agent Activity icon**: changed from `Bell` to `Pulse` (Phosphor) in the navbar dropdown - **Centered dialog buttons**: the "Stay in the loop" permission dialog buttons are now centered instead of right-aligned - **Service worker notification fix**: wrapped `new Notification()` in try-catch so it degrades gracefully in service worker / PWA contexts instead of throwing `TypeError: Illegal constructor` - **Persist notification state**: `completedSessionIDs` is now stored in localStorage (`copilot-completed-sessions`) so it survives page refreshes and new tabs - **Cross-tab sync**: a `storage` event listener keeps `completedSessionIDs` and `document.title` in sync across all open tabs — clearing a notification in one tab clears it everywhere - **Fix notification sound**: corrected the sound file path from `/sounds/notification.mp3` to `/notification.mp3` and added a `.gitignore` exception (root `.gitignore` has a blanket `*.mp3` ignore rule from legacy AutoGPT agent days) - **Fix SSR Sentry noise**: guarded the Copilot Zustand store initialization with a client-side check so `storage.get()` is never called during SSR, eliminating spurious Sentry alerts (BUILDER-7CB, 7CC, 7C7) while keeping the Sentry reporting in `local-storage.ts` intact for genuinely unexpected SSR access ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Verify "AutoPilot" appears (not "Otto") in browser notification, document title, permission dialog, and banner - [x] Verify Pulse icon in navbar Agent Activity dropdown - [x] Verify "Stay in the loop" dialog buttons are centered - [x] Open two tabs on copilot → trigger completion → both tabs show badge/checkmark - [x] Click completed session in tab 1 → badge clears in both tabs - [x] Refresh a tab → completed session state is preserved - [x] Verify notification sound plays on completion - [x] Verify no Sentry alerts from SSR localStorage access --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
a50e95f210
commit
09e42041ce
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,6 +17,7 @@ log-ingestion.txt
|
||||
/logs
|
||||
*.log
|
||||
*.mp3
|
||||
!autogpt_platform/frontend/public/notification.mp3
|
||||
mem.sqlite3
|
||||
venvAutoGPT
|
||||
|
||||
|
||||
BIN
autogpt_platform/frontend/public/notification.mp3
Normal file
BIN
autogpt_platform/frontend/public/notification.mp3
Normal file
Binary file not shown.
@@ -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 =
|
||||
|
||||
@@ -56,8 +56,8 @@ export function NotificationBanner() {
|
||||
<div className="flex items-center gap-3 border-b border-amber-200 bg-amber-50 px-4 py-2.5">
|
||||
<BellRinging className="h-5 w-5 shrink-0 text-amber-600" weight="fill" />
|
||||
<Text variant="body" className="flex-1 text-sm text-amber-800">
|
||||
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.
|
||||
</Text>
|
||||
<Button variant="primary" size="small" onClick={handleEnable}>
|
||||
Enable
|
||||
|
||||
@@ -77,11 +77,12 @@ export function NotificationDialog() {
|
||||
<BellRinging className="h-6 w-6 text-violet-600" weight="fill" />
|
||||
</div>
|
||||
<Text variant="body" className="text-center text-neutral-600">
|
||||
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.
|
||||
</Text>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Dialog.Footer className="justify-center">
|
||||
<Button variant="secondary" onClick={handleDismiss}>
|
||||
Not now
|
||||
</Button>
|
||||
|
||||
@@ -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"]));
|
||||
});
|
||||
});
|
||||
@@ -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<string>` of
|
||||
* session IDs. Returns an empty set for `null`, malformed, or non-array values.
|
||||
*/
|
||||
export function parseSessionIDs(raw: string | null | undefined): Set<string> {
|
||||
if (!raw) return new Set();
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return Array.isArray(parsed)
|
||||
? new Set<string>(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.
|
||||
|
||||
@@ -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<string>) {
|
||||
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<CopilotUIState>((set) => ({
|
||||
isDrawerOpen: false,
|
||||
setDrawerOpen: (open) => set({ isDrawerOpen: open }),
|
||||
|
||||
completedSessionIDs: new Set<string>(),
|
||||
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<string>() }),
|
||||
clearAllCompletedSessions: () => {
|
||||
persistCompletedSessions(new Set());
|
||||
set({ completedSessionIDs: new Set<string>() });
|
||||
},
|
||||
|
||||
isNotificationsEnabled:
|
||||
isClient &&
|
||||
storage.get(Key.COPILOT_NOTIFICATIONS_ENABLED) === "true" &&
|
||||
typeof Notification !== "undefined" &&
|
||||
Notification.permission === "granted",
|
||||
@@ -69,7 +92,8 @@ export const useCopilotUIStore = create<CopilotUIState>((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<CopilotUIState>((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<string>(),
|
||||
isNotificationsEnabled: false,
|
||||
isSoundEnabled: true,
|
||||
});
|
||||
document.title = "AutoGPT";
|
||||
if (isClient) {
|
||||
document.title = ORIGINAL_TITLE;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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<HTMLAudioElement | null>(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]);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<Bell size={22} className="text-black" />
|
||||
<Pulse size={22} className="text-black" />
|
||||
|
||||
{activeCount > 0 && (
|
||||
<>
|
||||
|
||||
@@ -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 ? (
|
||||
<div
|
||||
className={`flex justify-end gap-4 pt-6 ${className}`}
|
||||
className={cn("flex justify-end gap-4 pt-6", className)}
|
||||
data-testid={testId}
|
||||
style={style}
|
||||
>
|
||||
@@ -25,7 +26,7 @@ export function BaseFooter({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`flex w-full items-end justify-end gap-4 pt-6 ${className}`}
|
||||
className={cn("flex w-full items-end justify-end gap-4 pt-6", className)}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user