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:
Krzysztof Czerwinski
2026-04-03 20:44:22 +09:00
committed by GitHub
parent a50e95f210
commit 09e42041ce
12 changed files with 217 additions and 31 deletions

1
.gitignore vendored
View File

@@ -17,6 +17,7 @@ log-ingestion.txt
/logs
*.log
*.mp3
!autogpt_platform/frontend/public/notification.mp3
mem.sqlite3
venvAutoGPT

Binary file not shown.

View File

@@ -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 =

View File

@@ -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

View File

@@ -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>

View File

@@ -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"]));
});
});

View File

@@ -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.

View File

@@ -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;
}
},
}));

View File

@@ -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]);
}

View File

@@ -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 && (
<>

View File

@@ -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}

View File

@@ -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) {