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,
|
error,
|
||||||
stop,
|
stop,
|
||||||
isReconnecting,
|
isReconnecting,
|
||||||
|
isSyncing,
|
||||||
createSession,
|
createSession,
|
||||||
onSend,
|
onSend,
|
||||||
isLoadingSession,
|
isLoadingSession,
|
||||||
@@ -135,6 +136,7 @@ export function CopilotPage() {
|
|||||||
isSessionError={isSessionError}
|
isSessionError={isSessionError}
|
||||||
isCreatingSession={isCreatingSession}
|
isCreatingSession={isCreatingSession}
|
||||||
isReconnecting={isReconnecting}
|
isReconnecting={isReconnecting}
|
||||||
|
isSyncing={isSyncing}
|
||||||
onCreateSession={createSession}
|
onCreateSession={createSession}
|
||||||
onSend={onSend}
|
onSend={onSend}
|
||||||
onStop={stop}
|
onStop={stop}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface ChatContainerProps {
|
|||||||
isCreatingSession: boolean;
|
isCreatingSession: boolean;
|
||||||
/** True when backend has an active stream but we haven't reconnected yet. */
|
/** True when backend has an active stream but we haven't reconnected yet. */
|
||||||
isReconnecting?: boolean;
|
isReconnecting?: boolean;
|
||||||
|
/** True while re-syncing session state after device wake. */
|
||||||
|
isSyncing?: boolean;
|
||||||
onCreateSession: () => void | Promise<string>;
|
onCreateSession: () => void | Promise<string>;
|
||||||
onSend: (message: string, files?: File[]) => void | Promise<void>;
|
onSend: (message: string, files?: File[]) => void | Promise<void>;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
@@ -35,6 +37,7 @@ export const ChatContainer = ({
|
|||||||
isSessionError,
|
isSessionError,
|
||||||
isCreatingSession,
|
isCreatingSession,
|
||||||
isReconnecting,
|
isReconnecting,
|
||||||
|
isSyncing,
|
||||||
onCreateSession,
|
onCreateSession,
|
||||||
onSend,
|
onSend,
|
||||||
onStop,
|
onStop,
|
||||||
@@ -46,6 +49,7 @@ export const ChatContainer = ({
|
|||||||
status === "streaming" ||
|
status === "streaming" ||
|
||||||
status === "submitted" ||
|
status === "submitted" ||
|
||||||
!!isReconnecting ||
|
!!isReconnecting ||
|
||||||
|
!!isSyncing ||
|
||||||
isLoadingSession ||
|
isLoadingSession ||
|
||||||
!!isSessionError;
|
!!isSessionError;
|
||||||
const inputLayoutId = "copilot-2-chat-input";
|
const inputLayoutId = "copilot-2-chat-input";
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
CircleNotch,
|
||||||
DotsThree,
|
DotsThree,
|
||||||
PlusCircleIcon,
|
PlusCircleIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
@@ -36,7 +37,6 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { useCopilotUIStore } from "../../store";
|
import { useCopilotUIStore } from "../../store";
|
||||||
import { NotificationToggle } from "./components/NotificationToggle/NotificationToggle";
|
import { NotificationToggle } from "./components/NotificationToggle/NotificationToggle";
|
||||||
import { DeleteChatDialog } from "../DeleteChatDialog/DeleteChatDialog";
|
import { DeleteChatDialog } from "../DeleteChatDialog/DeleteChatDialog";
|
||||||
import { PulseLoader } from "../PulseLoader/PulseLoader";
|
|
||||||
import { UsageLimits } from "../UsageLimits/UsageLimits";
|
import { UsageLimits } from "../UsageLimits/UsageLimits";
|
||||||
|
|
||||||
export function ChatSidebar() {
|
export function ChatSidebar() {
|
||||||
@@ -367,7 +367,10 @@ export function ChatSidebar() {
|
|||||||
{session.is_processing &&
|
{session.is_processing &&
|
||||||
session.id !== sessionId &&
|
session.id !== sessionId &&
|
||||||
!completedSessionIDs.has(session.id) && (
|
!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) &&
|
{completedSessionIDs.has(session.id) &&
|
||||||
session.id !== sessionId && (
|
session.id !== sessionId && (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { scrollbarStyles } from "@/components/styles/scrollbars";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
CircleNotch,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
SpeakerHigh,
|
SpeakerHigh,
|
||||||
SpeakerSlash,
|
SpeakerSlash,
|
||||||
@@ -13,7 +14,6 @@ import {
|
|||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { Drawer } from "vaul";
|
import { Drawer } from "vaul";
|
||||||
import { useCopilotUIStore } from "../../store";
|
import { useCopilotUIStore } from "../../store";
|
||||||
import { PulseLoader } from "../PulseLoader/PulseLoader";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -165,7 +165,10 @@ export function MobileDrawer({
|
|||||||
{session.is_processing &&
|
{session.is_processing &&
|
||||||
!completedSessionIDs.has(session.id) &&
|
!completedSessionIDs.has(session.id) &&
|
||||||
session.id !== currentSessionId && (
|
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) &&
|
{completedSessionIDs.has(session.id) &&
|
||||||
session.id !== currentSessionId && (
|
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";
|
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. */
|
/** Mark any in-progress tool parts as completed/errored so spinners stop. */
|
||||||
export function resolveInProgressTools(
|
export function resolveInProgressTools(
|
||||||
messages: UIMessage[],
|
messages: UIMessage[],
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export function useCopilotPage() {
|
|||||||
status,
|
status,
|
||||||
error,
|
error,
|
||||||
isReconnecting,
|
isReconnecting,
|
||||||
|
isSyncing,
|
||||||
isUserStoppingRef,
|
isUserStoppingRef,
|
||||||
} = useCopilotStream({
|
} = useCopilotStream({
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -349,6 +350,7 @@ export function useCopilotPage() {
|
|||||||
error,
|
error,
|
||||||
stop,
|
stop,
|
||||||
isReconnecting,
|
isReconnecting,
|
||||||
|
isSyncing,
|
||||||
isLoadingSession,
|
isLoadingSession,
|
||||||
isSessionError,
|
isSessionError,
|
||||||
isCreatingSession,
|
isCreatingSession,
|
||||||
|
|||||||
@@ -11,11 +11,18 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
import type { FileUIPart, UIMessage } from "ai";
|
import type { FileUIPart, UIMessage } from "ai";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
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_BASE_DELAY_MS = 1_000;
|
||||||
const RECONNECT_MAX_ATTEMPTS = 3;
|
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). */
|
/** Fetch a fresh JWT for direct backend requests (same pattern as WebSocket). */
|
||||||
async function getAuthHeaders(): Promise<Record<string, string>> {
|
async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||||
const { token, error } = await getWebSocketToken();
|
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
|
// Must be state (not ref) so that setting it triggers a re-render and
|
||||||
// recomputes `isReconnecting`.
|
// recomputes `isReconnecting`.
|
||||||
const [reconnectExhausted, setReconnectExhausted] = useState(false);
|
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) {
|
function handleReconnect(sid: string) {
|
||||||
if (isReconnectScheduledRef.current || !sid) return;
|
if (isReconnectScheduledRef.current || !sid) return;
|
||||||
@@ -159,19 +170,7 @@ export function useCopilotStream({
|
|||||||
// unnecessary reconnect cycles.
|
// unnecessary reconnect cycles.
|
||||||
await new Promise((r) => setTimeout(r, 500));
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
const result = await refetchSession();
|
const result = await refetchSession();
|
||||||
const d = result.data;
|
if (hasActiveBackendStream(result)) {
|
||||||
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) {
|
|
||||||
handleReconnect(sessionId);
|
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
|
// Hydrate messages from REST API when not actively streaming
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hydratedMessages || hydratedMessages.length === 0) return;
|
if (!hydratedMessages || hydratedMessages.length === 0) return;
|
||||||
@@ -322,6 +382,7 @@ export function useCopilotStream({
|
|||||||
hasShownDisconnectToast.current = false;
|
hasShownDisconnectToast.current = false;
|
||||||
isUserStoppingRef.current = false;
|
isUserStoppingRef.current = false;
|
||||||
setReconnectExhausted(false);
|
setReconnectExhausted(false);
|
||||||
|
setIsSyncing(false);
|
||||||
hasResumedRef.current.clear();
|
hasResumedRef.current.clear();
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(reconnectTimerRef.current);
|
clearTimeout(reconnectTimerRef.current);
|
||||||
@@ -424,6 +485,7 @@ export function useCopilotStream({
|
|||||||
status,
|
status,
|
||||||
error: isReconnecting || isUserStoppingRef.current ? undefined : error,
|
error: isReconnecting || isUserStoppingRef.current ? undefined : error,
|
||||||
isReconnecting,
|
isReconnecting,
|
||||||
|
isSyncing,
|
||||||
isUserStoppingRef,
|
isUserStoppingRef,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user