From 500b345b3b3f81370dedb3d7865c48475cf6ad63 Mon Sep 17 00:00:00 2001 From: Ubbe Date: Wed, 25 Mar 2026 20:15:33 +0800 Subject: [PATCH] fix(frontend): auto-reconnect copilot chat after device sleep/wake (#12519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds `visibilitychange`-based sleep/wake detection to the copilot chat — when the page becomes visible after >30s hidden, automatically refetch the session and either resume an active stream or hydrate completed messages - Blocks chat input during re-sync (`isSyncing` state) to prevent users from accidentally sending a message that overwrites the agent's completed work - Replaces `PulseLoader` with a spinning `CircleNotch` icon on sidebar session names for background streaming sessions (closer to ChatGPT's UX) ## How it works 1. When the page goes hidden, we record a timestamp 2. When the page becomes visible, we check elapsed time 3. If >30s elapsed (indicating sleep or long background), we refetch the session from the API 4. If backend still has `active_stream=true` → remove stale assistant message and resume SSE 5. If backend is done → the refetch triggers React Query invalidation which hydrates the completed messages 6. Chat input stays disabled (`isSyncing=true`) until re-sync completes ## Test plan - [ ] Open copilot, start a long-running agent task - [ ] Close laptop lid / lock screen for >30 seconds - [ ] Wake device — verify chat shows the agent's completed response (or resumes streaming) - [ ] Verify chat input is temporarily disabled during re-sync, then re-enables - [ ] Verify sidebar shows spinning icon (not pulse loader) for background sessions - [ ] Verify no duplicate messages appear after wake - [ ] Verify normal streaming (no sleep) still works as expected Resolves: [SECRT-2159](https://linear.app/autogpt/issue/SECRT-2159) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../app/(platform)/copilot/CopilotPage.tsx | 2 + .../ChatContainer/ChatContainer.tsx | 4 + .../components/ChatSidebar/ChatSidebar.tsx | 7 +- .../components/MobileDrawer/MobileDrawer.tsx | 7 +- .../PulseLoader/PulseLoader.module.css | 39 -------- .../components/PulseLoader/PulseLoader.tsx | 16 ---- .../src/app/(platform)/copilot/helpers.ts | 19 ++++ .../app/(platform)/copilot/useCopilotPage.ts | 2 + .../(platform)/copilot/useCopilotStream.ts | 90 ++++++++++++++++--- 9 files changed, 113 insertions(+), 73 deletions(-) delete mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/PulseLoader/PulseLoader.module.css delete mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/PulseLoader/PulseLoader.tsx diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx index d7bba83c39..9d481aa42e 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx @@ -65,6 +65,7 @@ export function CopilotPage() { error, stop, isReconnecting, + isSyncing, createSession, onSend, isLoadingSession, @@ -135,6 +136,7 @@ export function CopilotPage() { isSessionError={isSessionError} isCreatingSession={isCreatingSession} isReconnecting={isReconnecting} + isSyncing={isSyncing} onCreateSession={createSession} onSend={onSend} onStop={stop} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatContainer/ChatContainer.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatContainer/ChatContainer.tsx index b4f8ca75a3..1b49a055ea 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatContainer/ChatContainer.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatContainer/ChatContainer.tsx @@ -17,6 +17,8 @@ export interface ChatContainerProps { isCreatingSession: boolean; /** True when backend has an active stream but we haven't reconnected yet. */ isReconnecting?: boolean; + /** True while re-syncing session state after device wake. */ + isSyncing?: boolean; onCreateSession: () => void | Promise; onSend: (message: string, files?: File[]) => void | Promise; onStop: () => void; @@ -35,6 +37,7 @@ export const ChatContainer = ({ isSessionError, isCreatingSession, isReconnecting, + isSyncing, onCreateSession, onSend, onStop, @@ -46,6 +49,7 @@ export const ChatContainer = ({ status === "streaming" || status === "submitted" || !!isReconnecting || + !!isSyncing || isLoadingSession || !!isSessionError; const inputLayoutId = "copilot-2-chat-input"; diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatSidebar/ChatSidebar.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatSidebar/ChatSidebar.tsx index cc333da688..4df121847a 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatSidebar/ChatSidebar.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatSidebar/ChatSidebar.tsx @@ -25,6 +25,7 @@ import { import { cn } from "@/lib/utils"; import { CheckCircle, + CircleNotch, DotsThree, PlusCircleIcon, PlusIcon, @@ -36,7 +37,6 @@ 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"; import { UsageLimits } from "../UsageLimits/UsageLimits"; export function ChatSidebar() { @@ -367,7 +367,10 @@ export function ChatSidebar() { {session.is_processing && session.id !== sessionId && !completedSessionIDs.has(session.id) && ( - + )} {completedSessionIDs.has(session.id) && session.id !== sessionId && ( diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/MobileDrawer/MobileDrawer.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/MobileDrawer/MobileDrawer.tsx index 9fbcbc2bbf..09c8832707 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/MobileDrawer/MobileDrawer.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/MobileDrawer/MobileDrawer.tsx @@ -5,6 +5,7 @@ import { scrollbarStyles } from "@/components/styles/scrollbars"; import { cn } from "@/lib/utils"; import { CheckCircle, + CircleNotch, PlusIcon, SpeakerHigh, SpeakerSlash, @@ -13,7 +14,6 @@ import { } from "@phosphor-icons/react"; import { Drawer } from "vaul"; import { useCopilotUIStore } from "../../store"; -import { PulseLoader } from "../PulseLoader/PulseLoader"; interface Props { isOpen: boolean; @@ -165,7 +165,10 @@ export function MobileDrawer({ {session.is_processing && !completedSessionIDs.has(session.id) && session.id !== currentSessionId && ( - + )} {completedSessionIDs.has(session.id) && session.id !== currentSessionId && ( diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/PulseLoader/PulseLoader.module.css b/autogpt_platform/frontend/src/app/(platform)/copilot/components/PulseLoader/PulseLoader.module.css deleted file mode 100644 index 7f7efcf048..0000000000 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/PulseLoader/PulseLoader.module.css +++ /dev/null @@ -1,39 +0,0 @@ -.loader { - position: relative; - display: inline-block; - flex-shrink: 0; -} - -.loader::before, -.loader::after { - content: ""; - box-sizing: border-box; - width: 100%; - height: 100%; - border-radius: 50%; - background: currentColor; - position: absolute; - left: 0; - top: 0; - transform: scale(0); - opacity: 0; - animation: ripple 2s linear infinite; -} - -.loader::after { - animation-delay: 1s; -} - -@keyframes ripple { - 0% { - transform: scale(0); - opacity: 0.6; - } - 50% { - opacity: 0.3; - } - 100% { - transform: scale(1); - opacity: 0; - } -} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/PulseLoader/PulseLoader.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/PulseLoader/PulseLoader.tsx deleted file mode 100644 index 599874daaa..0000000000 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/PulseLoader/PulseLoader.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { cn } from "@/lib/utils"; -import styles from "./PulseLoader.module.css"; - -interface Props { - size?: number; - className?: string; -} - -export function PulseLoader({ size = 24, className }: Props) { - return ( -
- ); -} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts index b4215c3c17..3c64390deb 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts @@ -1,5 +1,24 @@ import type { UIMessage } from "ai"; +/** + * Check whether a refetchSession result indicates the backend still has an + * active SSE stream for this session. + */ +export function hasActiveBackendStream(result: { data?: unknown }): boolean { + const d = result.data; + return ( + d != null && + typeof d === "object" && + "status" in d && + d.status === 200 && + "data" in d && + d.data != null && + typeof d.data === "object" && + "active_stream" in d.data && + !!d.data.active_stream + ); +} + /** Mark any in-progress tool parts as completed/errored so spinners stop. */ export function resolveInProgressTools( messages: UIMessage[], diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts index 8194a9d117..bf82fc66ec 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts @@ -54,6 +54,7 @@ export function useCopilotPage() { status, error, isReconnecting, + isSyncing, isUserStoppingRef, } = useCopilotStream({ sessionId, @@ -349,6 +350,7 @@ export function useCopilotPage() { error, stop, isReconnecting, + isSyncing, isLoadingSession, isSessionError, isCreatingSession, diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotStream.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotStream.ts index 773e37967a..25a233809a 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotStream.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotStream.ts @@ -11,11 +11,18 @@ import { useQueryClient } from "@tanstack/react-query"; import { DefaultChatTransport } from "ai"; import type { FileUIPart, UIMessage } from "ai"; import { useEffect, useMemo, useRef, useState } from "react"; -import { deduplicateMessages, resolveInProgressTools } from "./helpers"; +import { + deduplicateMessages, + hasActiveBackendStream, + resolveInProgressTools, +} from "./helpers"; const RECONNECT_BASE_DELAY_MS = 1_000; const RECONNECT_MAX_ATTEMPTS = 3; +/** Minimum time the page must have been hidden to trigger a wake re-sync. */ +const WAKE_RESYNC_THRESHOLD_MS = 30_000; + /** Fetch a fresh JWT for direct backend requests (same pattern as WebSocket). */ async function getAuthHeaders(): Promise> { const { token, error } = await getWebSocketToken(); @@ -98,6 +105,10 @@ export function useCopilotStream({ // Must be state (not ref) so that setting it triggers a re-render and // recomputes `isReconnecting`. const [reconnectExhausted, setReconnectExhausted] = useState(false); + // True while performing a wake re-sync (blocks chat input). + const [isSyncing, setIsSyncing] = useState(false); + // Tracks the last time the page was hidden — used to detect sleep/wake gaps. + const lastHiddenAtRef = useRef(Date.now()); function handleReconnect(sid: string) { if (isReconnectScheduledRef.current || !sid) return; @@ -159,19 +170,7 @@ export function useCopilotStream({ // unnecessary reconnect cycles. await new Promise((r) => setTimeout(r, 500)); const result = await refetchSession(); - const d = result.data; - const backendActive = - d != null && - typeof d === "object" && - "status" in d && - d.status === 200 && - "data" in d && - d.data != null && - typeof d.data === "object" && - "active_stream" in d.data && - !!d.data.active_stream; - - if (backendActive) { + if (hasActiveBackendStream(result)) { handleReconnect(sessionId); } }, @@ -298,6 +297,67 @@ export function useCopilotStream({ } } + // Keep a ref to sessionId so the async wake handler can detect staleness. + const sessionIdRef = useRef(sessionId); + sessionIdRef.current = sessionId; + + // --------------------------------------------------------------------------- + // Wake detection: when the page becomes visible after being hidden for >30s + // (device sleep, tab backgrounded for a long time), refetch the session to + // pick up any messages the backend produced while the SSE was dead. + // --------------------------------------------------------------------------- + useEffect(() => { + async function handleWakeResync() { + const sid = sessionIdRef.current; + if (!sid) return; + + const elapsed = Date.now() - lastHiddenAtRef.current; + lastHiddenAtRef.current = Date.now(); + + if (document.visibilityState !== "visible") return; + if (elapsed < WAKE_RESYNC_THRESHOLD_MS) return; + + setIsSyncing(true); + try { + const result = await refetchSession(); + // Bail out if the session changed while the refetch was in flight. + if (sessionIdRef.current !== sid) return; + + if (hasActiveBackendStream(result)) { + // Stream is still running — resume SSE to pick up live chunks. + // Remove stale in-progress assistant message first (backend replays + // from "0-0"). + setMessages((prev) => { + if (prev.length > 0 && prev[prev.length - 1].role === "assistant") { + return prev.slice(0, -1); + } + return prev; + }); + await resumeStream(); + } + // If !backendActive, the refetch will update hydratedMessages via + // React Query, and the hydration effect below will merge them in. + } catch (err) { + console.warn("[copilot] wake re-sync failed", err); + } finally { + setIsSyncing(false); + } + } + + function onVisibilityChange() { + if (document.visibilityState === "hidden") { + lastHiddenAtRef.current = Date.now(); + } else { + handleWakeResync(); + } + } + + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + }, [refetchSession, setMessages, resumeStream]); + // Hydrate messages from REST API when not actively streaming useEffect(() => { if (!hydratedMessages || hydratedMessages.length === 0) return; @@ -322,6 +382,7 @@ export function useCopilotStream({ hasShownDisconnectToast.current = false; isUserStoppingRef.current = false; setReconnectExhausted(false); + setIsSyncing(false); hasResumedRef.current.clear(); return () => { clearTimeout(reconnectTimerRef.current); @@ -424,6 +485,7 @@ export function useCopilotStream({ status, error: isReconnecting || isUserStoppingRef.current ? undefined : error, isReconnecting, + isSyncing, isUserStoppingRef, }; }