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:
Ubbe
2026-03-25 20:15:33 +08:00
committed by GitHub
parent 995dd1b5f3
commit 500b345b3b
9 changed files with 113 additions and 73 deletions

View File

@@ -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}

View File

@@ -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";

View File

@@ -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 && (

View File

@@ -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 && (

View File

@@ -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;
}
}

View File

@@ -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 }}
/>
);
}

View File

@@ -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[],

View File

@@ -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,

View File

@@ -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,
};
}