Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions autogpt_platform/backend/backend/api/features/chat/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ class SessionSummaryResponse(BaseModel):
created_at: str
updated_at: str
title: str | None = None
is_processing: bool = False
Comment thread
Pwuts marked this conversation as resolved.
Outdated


class ListSessionsResponse(BaseModel):
Expand Down Expand Up @@ -184,13 +185,34 @@ 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:
from backend.data.redis_client import get_redis_async
Comment thread
kcze marked this conversation as resolved.
Outdated

redis = await get_redis_async()
chat_config = ChatConfig()
Comment thread
kcze marked this conversation as resolved.
Outdated
pipe = redis.pipeline()
Comment thread
Pwuts marked this conversation as resolved.
Outdated
for session in sessions:
pipe.hget(
f"{chat_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"
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

return ListSessionsResponse(
sessions=[
SessionSummaryResponse(
id=session.session_id,
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
],
Expand Down
5 changes: 5 additions & 0 deletions autogpt_platform/backend/backend/api/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,8 @@ class NotificationPayload(pydantic.BaseModel):

class OnboardingNotificationPayload(NotificationPayload):
step: OnboardingStep | None


class CopilotCompletionPayload(NotificationPayload):
Comment thread
kcze marked this conversation as resolved.
session_id: str
status: str
Comment thread
kcze marked this conversation as resolved.
Outdated
30 changes: 30 additions & 0 deletions autogpt_platform/backend/backend/copilot/stream_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,36 @@ 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:
from backend.api.model import CopilotCompletionPayload
Comment thread
kcze marked this conversation as resolved.
Outdated
from backend.data.notification_bus import (
AsyncRedisNotificationEventBus,
NotificationEvent,
)

bus = AsyncRedisNotificationEventBus()
Comment thread
Pwuts marked this conversation as resolved.
Outdated
await 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


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -117,6 +119,7 @@ export function CopilotPage() {
onDrop={handleDrop}
>
{isMobile && <MobileHeader onOpenDrawer={handleOpenDrawer} />}
<NotificationBanner />
{/* Drop overlay */}
<div
className={cn(
Expand Down Expand Up @@ -201,6 +204,7 @@ export function CopilotPage() {
onCancel={handleCancelDelete}
/>
)}
<NotificationDialog />
</SidebarProvider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,24 +23,76 @@ import {
useSidebar,
} from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
import { DotsThree, PlusCircleIcon, PlusIcon } from "@phosphor-icons/react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/molecules/Popover/Popover";
import { Switch } from "@/components/atoms/Switch/Switch";
import {
Bell,
BellRinging,
BellSlash,
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 { 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,
isNotificationsEnabled,
setNotificationsEnabled,
isSoundEnabled,
toggleSound,
setShowNotificationDialog,
clearCopilotLocalData,
} = 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",
});
}
}

const queryClient = useQueryClient();

const { data: sessionsResponse, isLoading: isLoadingSessions } =
useGetV2ListSessions({ limit: 50 });
useGetV2ListSessions({ limit: 50 }, { query: { refetchInterval: 10_000 } });

const { mutate: deleteSession, isPending: isDeleting } =
useDeleteV2DeleteSession({
Expand Down Expand Up @@ -226,8 +278,67 @@ export function ChatSidebar() {
<Text variant="h3" size="body-medium">
Your chats
</Text>
<div className="relative left-6">
<SidebarTrigger />
<div className="flex items-center gap-1">
<Popover>
Comment thread
Pwuts marked this conversation as resolved.
Outdated
<PopoverTrigger asChild>
<button
className="rounded p-1 text-zinc-600 transition-colors hover:text-zinc-800"
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>
<hr className="border-zinc-200" />
<button
onClick={() => setShowNotificationDialog(true)}
className="rounded px-1 py-1.5 text-left text-sm text-zinc-700 hover:bg-zinc-100"
>
Show notification popup
</button>
<button
onClick={clearCopilotLocalData}
className="rounded px-1 py-1.5 text-left text-sm text-red-600 hover:bg-red-50"
>
Clear local data
</button>
</div>
</PopoverContent>
</Popover>
<div className="relative left-6">
<SidebarTrigger />
</div>
</div>
</div>
<Button
Expand Down Expand Up @@ -298,11 +409,21 @@ export function ChatSidebar() {
</div>
) : (
<button
onClick={() => handleSelectSession(session.id)}
onClick={() => {
handleSelectSession(session.id);
if (completedSessionIDs.has(session.id)) {
clearCompletedSession(session.id);
const remaining = completedSessionIDs.size - 1;
document.title =
remaining > 0
? `(${remaining}) Otto is ready - AutoGPT`
: "AutoGPT";
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}}
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(
Expand All @@ -325,10 +446,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>
)}
Expand Down
Loading