feat (ui): reconnect desktop ACP sessions after sleep and connection loss (#10411)
This commit is contained in:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user