mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
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:
committed by
GitHub
parent
83e49f71cd
commit
bc6b82218a
@@ -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
|
||||
],
|
||||
|
||||
@@ -94,3 +94,8 @@ class NotificationPayload(pydantic.BaseModel):
|
||||
|
||||
class OnboardingNotificationPayload(NotificationPayload):
|
||||
step: OnboardingStep | None
|
||||
|
||||
|
||||
class CopilotCompletionPayload(NotificationPayload):
|
||||
session_id: str
|
||||
status: Literal["completed", "failed"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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)."
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user