feat (ui): reconnect desktop ACP sessions after sleep and connection loss (#10411)

This commit is contained in:
Lifei Zhou
2026-07-14 09:12:03 +10:00
committed by GitHub
parent 97f150db60
commit da0cd42513
30 changed files with 725 additions and 77 deletions
+16 -1
View File
@@ -29,6 +29,7 @@ import { useAutoSubmit } from '../hooks/useAutoSubmit';
import { Goose } from './icons';
import EnvironmentBadge from './GooseSidebar/EnvironmentBadge';
import SessionActionsHeader from './SessionActionsHeader';
import { isAcpRecovering, subscribeToAcpRecovery } from '../acp/acpConnection';
const i18n = defineMessages({
failedToLoadSession: {
@@ -39,6 +40,10 @@ const i18n = defineMessages({
id: 'baseChat.goHome',
defaultMessage: 'Go home',
},
reconnecting: {
id: 'baseChat.reconnecting',
defaultMessage: 'Connection lost. Reconnecting…',
},
});
interface BaseChatProps {
@@ -75,6 +80,7 @@ export default function BaseChat({
const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false);
const [hasNotAcceptedRecipe, setHasNotAcceptedRecipe] = useState<boolean>();
const [hasRecipeSecurityWarnings, setHasRecipeSecurityWarnings] = useState(false);
const [acpRecovering, setAcpRecovering] = useState(isAcpRecovering);
const isMobile = useIsMobile();
const navContext = useNavigationContextSafe();
const setView = useNavigation();
@@ -83,6 +89,8 @@ export default function BaseChat({
const { droppedFiles, setDroppedFiles, handleDrop, handleDragOver } = useFileDrop();
const onStreamFinish = useCallback(() => {}, []);
useEffect(() => subscribeToAcpRecovery(setAcpRecovering), []);
const {
session,
messages,
@@ -133,6 +141,7 @@ export default function BaseChat({
// such as forks or resumes should auto-submit normally.
const suppressInitialAutoSubmit = noAutoSubmit && messages.length === 0;
const canAutoSubmit =
!acpRecovering &&
!suppressInitialAutoSubmit &&
(session?.session_type === 'scheduled' || !recipe || hasNotAcceptedRecipe === false);
@@ -470,6 +479,12 @@ export default function BaseChat({
)}
</div>
{acpRecovering && (
<div role="status" className="mx-4 mb-2 text-sm text-text-secondary">
{intl.formatMessage(i18n.reconnecting)}
</div>
)}
<ChatInputCard
className={cn(
'relative z-10 mx-4 mb-4',
@@ -484,7 +499,7 @@ export default function BaseChat({
onStop={stopStreaming}
onSteerQueuedMessage={onSteerQueuedMessage}
pauseQueueOnStop={pauseQueueOnStop}
queueProcessingBlocked={queueProcessingBlocked}
queueProcessingBlocked={queueProcessingBlocked || acpRecovering}
commandHistory={commandHistory}
initialValue={initialPrompt}
setView={setView}
@@ -0,0 +1,50 @@
import { render } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import ChatSessionsContainer from './ChatSessionsContainer';
import { subscribeToAcpRecovery } from '../acp/acpConnection';
import { acpChatSessionController } from '../acp/chatSessionController';
vi.mock('react-router-dom', () => ({
useSearchParams: () => [new URLSearchParams('resumeSessionId=session-1')],
}));
vi.mock('./BaseChat', () => ({
default: ({ sessionId }: { sessionId: string }) => <div>{sessionId}</div>,
}));
vi.mock('../acp/acpConnection', () => ({
subscribeToAcpRecovery: vi.fn(),
}));
vi.mock('../acp/chatSessionController', () => ({
acpChatSessionController: {
restoreSession: vi.fn().mockResolvedValue(undefined),
},
}));
describe('ChatSessionsContainer', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('restores active chat sessions after ACP reconnects', () => {
let onRecoveryChanged: ((recovering: boolean) => void) | undefined;
vi.mocked(subscribeToAcpRecovery).mockImplementation((listener) => {
onRecoveryChanged = listener;
return () => undefined;
});
render(
<ChatSessionsContainer
setChat={vi.fn()}
activeSessions={[{ sessionId: 'session-1' }, { sessionId: 'session-2' }]}
/>
);
onRecoveryChanged?.(false);
expect(acpChatSessionController.restoreSession).toHaveBeenCalledTimes(2);
expect(acpChatSessionController.restoreSession).toHaveBeenCalledWith('session-1');
expect(acpChatSessionController.restoreSession).toHaveBeenCalledWith('session-2');
});
});
@@ -1,7 +1,10 @@
import { useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import BaseChat from './BaseChat';
import { ChatType } from '../types/chat';
import { UserInput } from '../types/message';
import { subscribeToAcpRecovery } from '../acp/acpConnection';
import { acpChatSessionController } from '../acp/chatSessionController';
interface ChatSessionsContainerProps {
setChat: (chat: ChatType) => void;
@@ -24,11 +27,6 @@ export default function ChatSessionsContainer({
const [searchParams] = useSearchParams();
const currentSessionId = searchParams.get('resumeSessionId') ?? undefined;
// Always render active sessions to keep SSE connections alive, even when not on /pair route
if (!currentSessionId && activeSessions.length === 0) {
return null;
}
// Build the list of sessions to render
let sessionsToRender = activeSessions;
@@ -37,6 +35,25 @@ export default function ChatSessionsContainer({
sessionsToRender = [...activeSessions, { sessionId: currentSessionId }];
}
const sessionIdsRef = useRef<string[]>([]);
sessionIdsRef.current = sessionsToRender.map((session) => session.sessionId);
useEffect(() => {
return subscribeToAcpRecovery((recovering) => {
if (recovering) {
return;
}
for (const sessionId of sessionIdsRef.current) {
void acpChatSessionController.restoreSession(sessionId);
}
});
}, []);
// Always render active sessions to keep SSE connections alive, even when not on /pair route
if (!currentSessionId && activeSessions.length === 0) {
return null;
}
return (
<div className="relative w-full h-full">
{sessionsToRender.map((session) => {