feat(platform): add autopilot notification system (#12364)

Adds a notification system for the Copilot (AutoPilot) so users know
when background chats finish processing — via in-app indicators, sounds,
browser notifications, and document title badges.

### Changes 🏗️

**Backend**
- Add `is_processing` field to `SessionSummaryResponse` — batch-checks
Redis for active stream status on each session in the list endpoint
- Fix `is_processing` always returning `false` due to bytes vs string
comparison (`b"running"` → `"running"`) with `decode_responses=True`
Redis client
- Add `CopilotCompletionPayload` model for WebSocket notification events
- Publish `copilot_completion` notification via WebSocket when a session
completes in `stream_registry.mark_session_completed`

**Frontend — Notification UI**
- Add `NotificationBanner` component — amber banner prompting users to
enable browser notifications (auto-hides when already enabled or
dismissed)
- Add `NotificationDialog` component — modal dialog for enabling
notifications, supports force-open from sidebar menu for testing
- Fix repeated word "response" in dialog copy

**Frontend — Sidebar**
- Add bell icon in sidebar header with popover menu containing:
- Notifications toggle (requests browser permission on enable; shows
toast if denied)
  - Sound toggle (disabled when notifications are off)
  - "Show notification popup" button (for testing the dialog)
  - "Clear local data" button (resets all copilot localStorage keys)
- Bell icon states: `BellSlash` (disabled), `Bell` (enabled, no sound),
`BellRinging` (enabled + sound)
- Add processing indicator (PulseLoader) and completion checkmark
(CheckCircle) inline with chat title, to the left of the hamburger menu
- Processing indicator hides immediately when completion arrives (no
overlap with checkmark)
- Fix PulseLoader initial flash — start at `scale(0); opacity: 0` with
smoother keyframes
- Add 10s polling (`refetchInterval`) to session list so `is_processing`
updates automatically
- Clear document title badge when navigating to a completed chat
- Remove duplicate "Your chats" heading that appeared in both
SidebarHeader and SidebarContent

**Frontend — Notification Hook (`useCopilotNotifications`)**
- Listen for `copilot_completion` WebSocket events
- Track completed sessions in Zustand store
- Play notification sound (only for background sessions, not active
chat)
- Update `document.title` with unread count badge
- Send browser `Notification` when tab is hidden, with click-to-navigate
to the completed chat
- Reset document title on tab focus

**Frontend — Store & Storage**
- Add `completedSessionIDs`, `isNotificationsEnabled`, `isSoundEnabled`,
`showNotificationDialog`, `clearCopilotLocalData` to Zustand store
- Persist notification and sound preferences in localStorage
- On init, validate `isNotificationsEnabled` against actual
`Notification.permission`
- Add localStorage keys: `COPILOT_NOTIFICATIONS_ENABLED`,
`COPILOT_SOUND_ENABLED`, `COPILOT_NOTIFICATION_BANNER_DISMISSED`,
`COPILOT_NOTIFICATION_DIALOG_DISMISSED`

**Mobile**
- Add processing/completion indicators and sound toggle to MobileDrawer

### 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] Open copilot, start a chat, switch to another chat — verify
processing indicator appears on the background chat
- [x] Wait for background chat to complete — verify checkmark appears,
processing indicator disappears
- [x] Enable notifications via bell menu — verify browser permission
prompt appears
- [x] With notifications enabled, complete a background chat while on
another tab — verify system notification appears with sound
- [x] Click system notification — verify it navigates to the completed
chat
- [x] Verify document title shows unread count and resets when
navigating to the chat or focusing the tab
  - [x] Toggle sound off — verify no sound plays on completion
- [x] Toggle notifications off — verify no sound, no system
notification, no badge
  - [x] Clear local data — verify all preferences reset
- [x] Verify notification banner hides when notifications already
enabled
- [x] Verify dialog auto-shows for first-time users and can be
force-opened from menu

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Czerwinski
2026-03-12 23:03:24 +09:00
committed by GitHub
parent 83e49f71cd
commit bc6b82218a
16 changed files with 641 additions and 25 deletions

View File

@@ -53,6 +53,7 @@ from backend.copilot.tools.models import (
UnderstandingUpdatedResponse,
)
from backend.copilot.tracking import track_user_message
from backend.data.redis_client import get_redis_async
from backend.data.workspace import get_or_create_workspace
from backend.util.exceptions import NotFoundError
@@ -127,6 +128,7 @@ class SessionSummaryResponse(BaseModel):
created_at: str
updated_at: str
title: str | None = None
is_processing: bool
class ListSessionsResponse(BaseModel):
@@ -185,6 +187,28 @@ async def list_sessions(
"""
sessions, total_count = await get_user_sessions(user_id, limit, offset)
# Batch-check Redis for active stream status on each session
processing_set: set[str] = set()
if sessions:
try:
redis = await get_redis_async()
pipe = redis.pipeline(transaction=False)
for session in sessions:
pipe.hget(
f"{config.session_meta_prefix}{session.session_id}",
"status",
)
statuses = await pipe.execute()
processing_set = {
session.session_id
for session, st in zip(sessions, statuses)
if st == "running"
}
except Exception:
logger.warning(
"Failed to fetch processing status from Redis; " "defaulting to empty"
)
return ListSessionsResponse(
sessions=[
SessionSummaryResponse(
@@ -192,6 +216,7 @@ async def list_sessions(
created_at=session.started_at.isoformat(),
updated_at=session.updated_at.isoformat(),
title=session.title,
is_processing=session.session_id in processing_set,
)
for session in sessions
],

View File

@@ -94,3 +94,8 @@ class NotificationPayload(pydantic.BaseModel):
class OnboardingNotificationPayload(NotificationPayload):
step: OnboardingStep | None
class CopilotCompletionPayload(NotificationPayload):
session_id: str
status: Literal["completed", "failed"]

View File

@@ -23,6 +23,11 @@ from typing import Any, Literal
import orjson
from backend.api.model import CopilotCompletionPayload
from backend.data.notification_bus import (
AsyncRedisNotificationEventBus,
NotificationEvent,
)
from backend.data.redis_client import get_redis_async
from .config import ChatConfig
@@ -38,6 +43,7 @@ from .response_model import (
logger = logging.getLogger(__name__)
config = ChatConfig()
_notification_bus = AsyncRedisNotificationEventBus()
# Track background tasks for this pod (just the asyncio.Task reference, not subscribers)
_local_sessions: dict[str, asyncio.Task] = {}
@@ -745,6 +751,29 @@ async def mark_session_completed(
# Clean up local session reference if exists
_local_sessions.pop(session_id, None)
# Publish copilot completion notification via WebSocket
if meta:
parsed = _parse_session_meta(meta, session_id)
if parsed.user_id:
try:
await _notification_bus.publish(
NotificationEvent(
user_id=parsed.user_id,
payload=CopilotCompletionPayload(
type="copilot_completion",
event="session_completed",
session_id=session_id,
status=status,
),
)
)
except Exception as e:
logger.warning(
f"Failed to publish copilot completion notification "
f"for session {session_id}: {e}"
)
return True

View File

@@ -8,6 +8,8 @@ from backend.api.model import NotificationPayload
from backend.data.event_bus import AsyncRedisEventBus
from backend.util.settings import Settings
_settings = Settings()
class NotificationEvent(BaseModel):
"""Generic notification event destined for websocket delivery."""
@@ -26,7 +28,7 @@ class AsyncRedisNotificationEventBus(AsyncRedisEventBus[NotificationEvent]):
@property
def event_bus_name(self) -> str:
return Settings().config.notification_event_bus_name
return _settings.config.notification_event_bus_name
async def publish(self, event: NotificationEvent) -> None:
await self.publish_event(event, event.user_id)

View File

@@ -15,6 +15,8 @@ import { ChatSidebar } from "./components/ChatSidebar/ChatSidebar";
import { DeleteChatDialog } from "./components/DeleteChatDialog/DeleteChatDialog";
import { MobileDrawer } from "./components/MobileDrawer/MobileDrawer";
import { MobileHeader } from "./components/MobileHeader/MobileHeader";
import { NotificationBanner } from "./components/NotificationBanner/NotificationBanner";
import { NotificationDialog } from "./components/NotificationDialog/NotificationDialog";
import { ScaleLoader } from "./components/ScaleLoader/ScaleLoader";
import { useCopilotPage } from "./useCopilotPage";
@@ -117,6 +119,7 @@ export function CopilotPage() {
onDrop={handleDrop}
>
{isMobile && <MobileHeader onOpenDrawer={handleOpenDrawer} />}
<NotificationBanner />
{/* Drop overlay */}
<div
className={cn(
@@ -201,6 +204,7 @@ export function CopilotPage() {
onCancel={handleCancelDelete}
/>
)}
<NotificationDialog />
</SidebarProvider>
);
}

View File

@@ -23,24 +23,36 @@ import {
useSidebar,
} from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
import { DotsThree, PlusCircleIcon, PlusIcon } from "@phosphor-icons/react";
import {
CheckCircle,
DotsThree,
PlusCircleIcon,
PlusIcon,
} from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query";
import { AnimatePresence, motion } from "framer-motion";
import { parseAsString, useQueryState } from "nuqs";
import { useEffect, useRef, useState } from "react";
import { useCopilotUIStore } from "../../store";
import { NotificationToggle } from "./components/NotificationToggle/NotificationToggle";
import { DeleteChatDialog } from "../DeleteChatDialog/DeleteChatDialog";
import { PulseLoader } from "../PulseLoader/PulseLoader";
export function ChatSidebar() {
const { state } = useSidebar();
const isCollapsed = state === "collapsed";
const [sessionId, setSessionId] = useQueryState("sessionId", parseAsString);
const { sessionToDelete, setSessionToDelete } = useCopilotUIStore();
const {
sessionToDelete,
setSessionToDelete,
completedSessionIDs,
clearCompletedSession,
} = useCopilotUIStore();
const queryClient = useQueryClient();
const { data: sessionsResponse, isLoading: isLoadingSessions } =
useGetV2ListSessions({ limit: 50 });
useGetV2ListSessions({ limit: 50 }, { query: { refetchInterval: 10_000 } });
const { mutate: deleteSession, isPending: isDeleting } =
useDeleteV2DeleteSession({
@@ -99,6 +111,22 @@ export function ChatSidebar() {
}
}, [editingSessionId]);
// Refetch session list when active session changes
useEffect(() => {
queryClient.invalidateQueries({
queryKey: getGetV2ListSessionsQueryKey(),
});
}, [sessionId, queryClient]);
// Clear completed indicator when navigating to a session (works for all paths)
useEffect(() => {
if (!sessionId || !completedSessionIDs.has(sessionId)) return;
clearCompletedSession(sessionId);
const remaining = completedSessionIDs.size - 1;
document.title =
remaining > 0 ? `(${remaining}) Otto is ready - AutoGPT` : "AutoGPT";
}, [sessionId, completedSessionIDs, clearCompletedSession]);
const sessions =
sessionsResponse?.status === 200 ? sessionsResponse.data.sessions : [];
@@ -228,8 +256,11 @@ export function ChatSidebar() {
<Text variant="h3" size="body-medium">
Your chats
</Text>
<div className="relative left-6">
<SidebarTrigger />
<div className="relative left-5 flex items-center gap-1">
<NotificationToggle />
<div className="relative left-1">
<SidebarTrigger />
</div>
</div>
</div>
{sessionId ? (
@@ -305,8 +336,8 @@ export function ChatSidebar() {
onClick={() => handleSelectSession(session.id)}
className="w-full px-3 py-2.5 pr-10 text-left"
>
<div className="flex min-w-0 max-w-full flex-col overflow-hidden">
<div className="min-w-0 max-w-full">
<div className="flex min-w-0 max-w-full items-center gap-2">
<div className="min-w-0 flex-1">
<Text
variant="body"
className={cn(
@@ -329,10 +360,22 @@ export function ChatSidebar() {
</motion.span>
</AnimatePresence>
</Text>
<Text variant="small" className="text-neutral-400">
{formatDate(session.updated_at)}
</Text>
</div>
<Text variant="small" className="text-neutral-400">
{formatDate(session.updated_at)}
</Text>
{session.is_processing &&
session.id !== sessionId &&
!completedSessionIDs.has(session.id) && (
<PulseLoader size={16} className="shrink-0" />
)}
{completedSessionIDs.has(session.id) &&
session.id !== sessionId && (
<CheckCircle
className="h-4 w-4 shrink-0 text-green-500"
weight="fill"
/>
)}
</div>
</button>
)}

View File

@@ -0,0 +1,92 @@
"use client";
import { Switch } from "@/components/atoms/Switch/Switch";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/molecules/Popover/Popover";
import { toast } from "@/components/molecules/Toast/use-toast";
import { cn } from "@/lib/utils";
import { Bell, BellRinging, BellSlash } from "@phosphor-icons/react";
import { useCopilotUIStore } from "../../../../store";
export function NotificationToggle() {
const {
isNotificationsEnabled,
setNotificationsEnabled,
isSoundEnabled,
toggleSound,
} = useCopilotUIStore();
async function handleToggleNotifications() {
if (isNotificationsEnabled) {
setNotificationsEnabled(false);
return;
}
if (typeof Notification === "undefined") {
toast({
title: "Notifications not supported",
description: "Your browser does not support notifications.",
variant: "destructive",
});
return;
}
const permission = await Notification.requestPermission();
if (permission === "granted") {
setNotificationsEnabled(true);
} else {
toast({
title: "Notifications blocked",
description:
"Please allow notifications in your browser settings to enable this feature.",
variant: "destructive",
});
}
}
return (
<Popover>
<PopoverTrigger asChild>
<button
className="rounded p-1 text-black transition-colors hover:bg-zinc-50"
aria-label="Notification settings"
>
{!isNotificationsEnabled ? (
<BellSlash className="!size-5" />
) : isSoundEnabled ? (
<BellRinging className="!size-5" />
) : (
<Bell className="!size-5" />
)}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-56 p-3">
<div className="flex flex-col gap-3">
<label className="flex items-center justify-between">
<span className="text-sm text-zinc-700">Notifications</span>
<Switch
checked={isNotificationsEnabled}
onCheckedChange={handleToggleNotifications}
/>
</label>
<label className="flex items-center justify-between">
<span
className={cn(
"text-sm text-zinc-700",
!isNotificationsEnabled && "opacity-50",
)}
>
Sound
</span>
<Switch
checked={isSoundEnabled && isNotificationsEnabled}
onCheckedChange={toggleSound}
disabled={!isNotificationsEnabled}
/>
</label>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -3,8 +3,17 @@ import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { scrollbarStyles } from "@/components/styles/scrollbars";
import { cn } from "@/lib/utils";
import { PlusIcon, SpinnerGapIcon, X } from "@phosphor-icons/react";
import {
CheckCircle,
PlusIcon,
SpeakerHigh,
SpeakerSlash,
SpinnerGapIcon,
X,
} from "@phosphor-icons/react";
import { Drawer } from "vaul";
import { useCopilotUIStore } from "../../store";
import { PulseLoader } from "../PulseLoader/PulseLoader";
interface Props {
isOpen: boolean;
@@ -52,6 +61,13 @@ export function MobileDrawer({
onClose,
onOpenChange,
}: Props) {
const {
completedSessionIDs,
clearCompletedSession,
isSoundEnabled,
toggleSound,
} = useCopilotUIStore();
return (
<Drawer.Root open={isOpen} onOpenChange={onOpenChange} direction="left">
<Drawer.Portal>
@@ -62,14 +78,31 @@ export function MobileDrawer({
<Drawer.Title className="text-lg font-semibold text-zinc-800">
Your chats
</Drawer.Title>
<Button
variant="icon"
size="icon"
aria-label="Close sessions"
onClick={onClose}
>
<X width="1rem" height="1rem" />
</Button>
<div className="flex items-center gap-1">
<button
onClick={toggleSound}
className="rounded p-1.5 text-zinc-400 transition-colors hover:text-zinc-600"
aria-label={
isSoundEnabled
? "Disable notification sound"
: "Enable notification sound"
}
>
{isSoundEnabled ? (
<SpeakerHigh className="h-4 w-4" />
) : (
<SpeakerSlash className="h-4 w-4" />
)}
</button>
<Button
variant="icon"
size="icon"
aria-label="Close sessions"
onClick={onClose}
>
<X width="1rem" height="1rem" />
</Button>
</div>
</div>
{currentSessionId ? (
<div className="mt-2">
@@ -103,7 +136,12 @@ export function MobileDrawer({
sessions.map((session) => (
<button
key={session.id}
onClick={() => onSelectSession(session.id)}
onClick={() => {
onSelectSession(session.id);
if (completedSessionIDs.has(session.id)) {
clearCompletedSession(session.id);
}
}}
className={cn(
"w-full rounded-lg px-3 py-2.5 text-left transition-colors",
session.id === currentSessionId
@@ -112,7 +150,7 @@ export function MobileDrawer({
)}
>
<div className="flex min-w-0 max-w-full flex-col overflow-hidden">
<div className="min-w-0 max-w-full">
<div className="flex min-w-0 max-w-full items-center gap-1.5">
<Text
variant="body"
className={cn(
@@ -124,6 +162,18 @@ export function MobileDrawer({
>
{session.title || "Untitled chat"}
</Text>
{session.is_processing &&
!completedSessionIDs.has(session.id) &&
session.id !== currentSessionId && (
<PulseLoader size={8} className="shrink-0" />
)}
{completedSessionIDs.has(session.id) &&
session.id !== currentSessionId && (
<CheckCircle
className="h-4 w-4 shrink-0 text-green-500"
weight="fill"
/>
)}
</div>
<Text variant="small" className="text-neutral-400">
{formatDate(session.updated_at)}

View File

@@ -0,0 +1,74 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { Key, storage } from "@/services/storage/local-storage";
import { BellRinging, X } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
import { useCopilotUIStore } from "../../store";
export function NotificationBanner() {
const { setNotificationsEnabled, isNotificationsEnabled } =
useCopilotUIStore();
const [dismissed, setDismissed] = useState(
() => storage.get(Key.COPILOT_NOTIFICATION_BANNER_DISMISSED) === "true",
);
const [permission, setPermission] = useState(() =>
typeof Notification !== "undefined" ? Notification.permission : "denied",
);
// Re-read dismissed flag when notifications are toggled off (e.g. clearCopilotLocalData)
useEffect(() => {
if (!isNotificationsEnabled) {
setDismissed(
storage.get(Key.COPILOT_NOTIFICATION_BANNER_DISMISSED) === "true",
);
}
}, [isNotificationsEnabled]);
// Don't show if notifications aren't supported, already decided, dismissed, or already enabled
if (
typeof Notification === "undefined" ||
permission !== "default" ||
dismissed ||
isNotificationsEnabled
) {
return null;
}
function handleEnable() {
Notification.requestPermission().then((result) => {
setPermission(result);
if (result === "granted") {
setNotificationsEnabled(true);
handleDismiss();
}
});
}
function handleDismiss() {
storage.set(Key.COPILOT_NOTIFICATION_BANNER_DISMISSED, "true");
setDismissed(true);
}
return (
<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.
</Text>
<Button variant="primary" size="small" onClick={handleEnable}>
Enable
</Button>
<button
onClick={handleDismiss}
className="rounded p-1 text-amber-400 transition-colors hover:text-amber-600"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
</div>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { Key, storage } from "@/services/storage/local-storage";
import { BellRinging } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
import { useCopilotUIStore } from "../../store";
export function NotificationDialog() {
const {
showNotificationDialog,
setShowNotificationDialog,
setNotificationsEnabled,
isNotificationsEnabled,
} = useCopilotUIStore();
const [dismissed, setDismissed] = useState(
() => storage.get(Key.COPILOT_NOTIFICATION_DIALOG_DISMISSED) === "true",
);
const [permission, setPermission] = useState(() =>
typeof Notification !== "undefined" ? Notification.permission : "denied",
);
// Re-read dismissed flag when notifications are toggled off (e.g. clearCopilotLocalData)
useEffect(() => {
if (!isNotificationsEnabled) {
setDismissed(
storage.get(Key.COPILOT_NOTIFICATION_DIALOG_DISMISSED) === "true",
);
}
}, [isNotificationsEnabled]);
const shouldShowAuto =
typeof Notification !== "undefined" &&
permission === "default" &&
!dismissed;
const isOpen = showNotificationDialog || shouldShowAuto;
function handleEnable() {
if (typeof Notification === "undefined") {
handleDismiss();
return;
}
Notification.requestPermission().then((result) => {
setPermission(result);
if (result === "granted") {
setNotificationsEnabled(true);
handleDismiss();
}
});
}
function handleDismiss() {
storage.set(Key.COPILOT_NOTIFICATION_DIALOG_DISMISSED, "true");
setDismissed(true);
setShowNotificationDialog(false);
}
return (
<Dialog
title="Stay in the loop"
styling={{ maxWidth: "28rem", minWidth: "auto" }}
controlled={{
isOpen,
set: async (open) => {
if (!open) handleDismiss();
},
}}
onClose={handleDismiss}
>
<Dialog.Content>
<div className="flex flex-col items-center gap-4 py-2">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-violet-100">
<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.
</Text>
</div>
<Dialog.Footer>
<Button variant="secondary" onClick={handleDismiss}>
Not now
</Button>
<Button variant="primary" onClick={handleEnable}>
Enable notifications
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -15,6 +15,8 @@
position: absolute;
left: 0;
top: 0;
transform: scale(0);
opacity: 0;
animation: ripple 2s linear infinite;
}
@@ -25,7 +27,10 @@
@keyframes ripple {
0% {
transform: scale(0);
opacity: 1;
opacity: 0.6;
}
50% {
opacity: 0.3;
}
100% {
transform: scale(1);

View File

@@ -1,3 +1,4 @@
import { Key, storage } from "@/services/storage/local-storage";
import { create } from "zustand";
export interface DeleteTarget {
@@ -11,6 +12,22 @@ interface CopilotUIState {
isDrawerOpen: boolean;
setDrawerOpen: (open: boolean) => void;
completedSessionIDs: Set<string>;
addCompletedSession: (id: string) => void;
clearCompletedSession: (id: string) => void;
clearAllCompletedSessions: () => void;
isNotificationsEnabled: boolean;
setNotificationsEnabled: (enabled: boolean) => void;
isSoundEnabled: boolean;
toggleSound: () => void;
showNotificationDialog: boolean;
setShowNotificationDialog: (show: boolean) => void;
clearCopilotLocalData: () => void;
}
export const useCopilotUIStore = create<CopilotUIState>((set) => ({
@@ -19,4 +36,53 @@ export const useCopilotUIStore = create<CopilotUIState>((set) => ({
isDrawerOpen: false,
setDrawerOpen: (open) => set({ isDrawerOpen: open }),
completedSessionIDs: new Set<string>(),
addCompletedSession: (id) =>
set((state) => {
const next = new Set(state.completedSessionIDs);
next.add(id);
return { completedSessionIDs: next };
}),
clearCompletedSession: (id) =>
set((state) => {
const next = new Set(state.completedSessionIDs);
next.delete(id);
return { completedSessionIDs: next };
}),
clearAllCompletedSessions: () =>
set({ completedSessionIDs: new Set<string>() }),
isNotificationsEnabled:
storage.get(Key.COPILOT_NOTIFICATIONS_ENABLED) === "true" &&
typeof Notification !== "undefined" &&
Notification.permission === "granted",
setNotificationsEnabled: (enabled) => {
storage.set(Key.COPILOT_NOTIFICATIONS_ENABLED, String(enabled));
set({ isNotificationsEnabled: enabled });
},
isSoundEnabled: storage.get(Key.COPILOT_SOUND_ENABLED) !== "false",
toggleSound: () =>
set((state) => {
const next = !state.isSoundEnabled;
storage.set(Key.COPILOT_SOUND_ENABLED, String(next));
return { isSoundEnabled: next };
}),
showNotificationDialog: false,
setShowNotificationDialog: (show) => set({ showNotificationDialog: show }),
clearCopilotLocalData: () => {
storage.clean(Key.COPILOT_NOTIFICATIONS_ENABLED);
storage.clean(Key.COPILOT_SOUND_ENABLED);
storage.clean(Key.COPILOT_NOTIFICATION_BANNER_DISMISSED);
storage.clean(Key.COPILOT_NOTIFICATION_DIALOG_DISMISSED);
set({
completedSessionIDs: new Set<string>(),
isNotificationsEnabled: false,
isSoundEnabled: true,
});
document.title = "AutoGPT";
},
}));

View File

@@ -0,0 +1,118 @@
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import type { WebSocketNotification } from "@/lib/autogpt-server-api/types";
import { useEffect, useRef } from "react";
import { useCopilotUIStore } from "./store";
const ORIGINAL_TITLE = "AutoGPT";
const NOTIFICATION_SOUND_PATH = "/sounds/notification.mp3";
/**
* Listens for copilot completion notifications via WebSocket.
* Updates the Zustand store, plays a sound, and updates document.title.
*/
export function useCopilotNotifications(activeSessionID: string | null) {
const api = useBackendAPI();
const audioRef = useRef<HTMLAudioElement | null>(null);
const activeSessionRef = useRef(activeSessionID);
activeSessionRef.current = activeSessionID;
const windowFocusedRef = useRef(true);
// Pre-load audio element
useEffect(() => {
if (typeof window === "undefined") return;
const audio = new Audio(NOTIFICATION_SOUND_PATH);
audio.volume = 0.5;
audioRef.current = audio;
}, []);
// Listen for WebSocket notifications
useEffect(() => {
function handleNotification(notification: WebSocketNotification) {
if (notification.type !== "copilot_completion") return;
if (notification.event !== "session_completed") return;
const sessionID = (notification as Record<string, unknown>).session_id;
if (typeof sessionID !== "string") return;
const state = useCopilotUIStore.getState();
const isActiveSession = sessionID === activeSessionRef.current;
const isUserAway =
document.visibilityState === "hidden" || !windowFocusedRef.current;
// Skip if viewing the active session and it's in focus
if (isActiveSession && !isUserAway) return;
// Skip if we already notified for this session (e.g. WS replay)
if (state.completedSessionIDs.has(sessionID)) return;
// 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}`;
// Sound and browser notifications are gated by the user setting
if (!state.isNotificationsEnabled) return;
if (state.isSoundEnabled && audioRef.current) {
audioRef.current.currentTime = 0;
audioRef.current.play().catch(() => {});
}
// Send browser notification when user is away
if (
typeof Notification !== "undefined" &&
Notification.permission === "granted" &&
isUserAway
) {
const n = new Notification("Otto is ready", {
body: "A response is waiting for you.",
icon: "/favicon.ico",
});
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();
};
}
}
const detach = api.onWebSocketMessage("notification", handleNotification);
return () => {
detach();
};
}, [api]);
// Track window focus for browser notifications when app is in background
useEffect(() => {
function handleFocus() {
windowFocusedRef.current = true;
if (useCopilotUIStore.getState().completedSessionIDs.size === 0) {
document.title = ORIGINAL_TITLE;
}
}
function handleBlur() {
windowFocusedRef.current = false;
}
function handleVisibilityChange() {
if (
document.visibilityState === "visible" &&
useCopilotUIStore.getState().completedSessionIDs.size === 0
) {
document.title = ORIGINAL_TITLE;
}
}
window.addEventListener("focus", handleFocus);
window.addEventListener("blur", handleBlur);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("focus", handleFocus);
window.removeEventListener("blur", handleBlur);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
}

View File

@@ -13,6 +13,7 @@ import type { FileUIPart } from "ai";
import { useEffect, useRef, useState } from "react";
import { useCopilotUIStore } from "./store";
import { useChatSession } from "./useChatSession";
import { useCopilotNotifications } from "./useCopilotNotifications";
import { useCopilotStream } from "./useCopilotStream";
const TITLE_POLL_INTERVAL_MS = 2_000;
@@ -60,6 +61,8 @@ export function useCopilotPage() {
refetchSession,
});
useCopilotNotifications(sessionId);
// --- Delete session ---
const { mutate: deleteSessionMutation, isPending: isDeleting } =
useDeleteV2DeleteSession({

View File

@@ -11829,10 +11829,11 @@
"title": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Title"
}
},
"is_processing": { "type": "boolean", "title": "Is Processing" }
},
"type": "object",
"required": ["id", "created_at", "updated_at"],
"required": ["id", "created_at", "updated_at", "is_processing"],
"title": "SessionSummaryResponse",
"description": "Response model for a session summary (without messages)."
},

View File

@@ -11,6 +11,10 @@ export enum Key {
CHAT_SESSION_ID = "chat_session_id",
COOKIE_CONSENT = "autogpt_cookie_consent",
AI_AGENT_SAFETY_POPUP_SHOWN = "ai-agent-safety-popup-shown",
COPILOT_SOUND_ENABLED = "copilot-sound-enabled",
COPILOT_NOTIFICATIONS_ENABLED = "copilot-notifications-enabled",
COPILOT_NOTIFICATION_BANNER_DISMISSED = "copilot-notification-banner-dismissed",
COPILOT_NOTIFICATION_DIALOG_DISMISSED = "copilot-notification-dialog-dismissed",
}
function get(key: Key) {