mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
fix(frontend): auto-reconnect copilot chat after device sleep/wake (#12519)
## 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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<string>;
|
||||
onSend: (message: string, files?: File[]) => void | Promise<void>;
|
||||
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";
|
||||
|
||||
@@ -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) && (
|
||||
<PulseLoader size={16} className="shrink-0" />
|
||||
<CircleNotch
|
||||
className="h-4 w-4 shrink-0 animate-spin text-zinc-400"
|
||||
weight="bold"
|
||||
/>
|
||||
)}
|
||||
{completedSessionIDs.has(session.id) &&
|
||||
session.id !== sessionId && (
|
||||
|
||||
@@ -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 && (
|
||||
<PulseLoader size={8} className="shrink-0" />
|
||||
<CircleNotch
|
||||
className="h-4 w-4 shrink-0 animate-spin text-zinc-400"
|
||||
weight="bold"
|
||||
/>
|
||||
)}
|
||||
{completedSessionIDs.has(session.id) &&
|
||||
session.id !== currentSessionId && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(styles.loader, className)}
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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[],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Record<string, string>> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user