feat (ui): reconnect desktop ACP sessions after sleep and connection loss (#10411)
This commit is contained in:
@@ -9,6 +9,7 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { AppInner, resolveSessionInitialMessage } from './App';
|
||||
import { IntlTestWrapper } from './i18n/test-utils';
|
||||
import { FeaturesProvider } from './contexts/FeaturesContext';
|
||||
import { reconnectAcpAfterSystemResume } from './acp/acpConnection';
|
||||
|
||||
// Set up globals for jsdom
|
||||
Object.defineProperty(window, 'location', {
|
||||
@@ -51,6 +52,11 @@ vi.mock('./acp/capabilities', () => ({
|
||||
getAcpFeatureCapabilities: vi.fn().mockResolvedValue({ localInference: true }),
|
||||
}));
|
||||
|
||||
vi.mock('./acp/acpConnection', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./acp/acpConnection')>()),
|
||||
reconnectAcpAfterSystemResume: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the ACP providers module used by OnboardingGuard so it doesn't try to
|
||||
// open a real ACP client connection during tests. Returning null defaults
|
||||
// keeps the app in the "brand new" (no provider configured) onboarding state.
|
||||
@@ -301,6 +307,23 @@ describe('App Component - Brand New State', () => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
it('should reconnect ACP when the main process emits system-resume', async () => {
|
||||
render(<AppInner />, { wrapper: AppInnerTestWrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockElectron.reactReady).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const systemResumeHandler = mockElectron.on.mock.calls.find(
|
||||
([channel]) => channel === 'system-resume'
|
||||
)?.[1];
|
||||
expect(systemResumeHandler).toBeDefined();
|
||||
|
||||
systemResumeHandler?.({} as any);
|
||||
|
||||
expect(reconnectAcpAfterSystemResume).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should seed recipe sessions with the recipe prompt when no initial message is provided', () => {
|
||||
expect(
|
||||
resolveSessionInitialMessage(
|
||||
|
||||
@@ -58,6 +58,7 @@ import { usePageViewTracking } from './hooks/useAnalytics';
|
||||
import { trackErrorWithContext } from './utils/analytics';
|
||||
import { AppEvents } from './constants/events';
|
||||
import { registerPlatformEventHandlers } from './utils/platform_events';
|
||||
import { reconnectAcpAfterSystemResume } from './acp/acpConnection';
|
||||
|
||||
function PageViewTracker() {
|
||||
usePageViewTracking();
|
||||
@@ -396,6 +397,12 @@ export function AppInner() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSystemResume = () => reconnectAcpAfterSystemResume();
|
||||
window.electron.on('system-resume', handleSystemResume);
|
||||
return () => window.electron.off('system-resume', handleSystemResume);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
acpListSessions()
|
||||
.then(({ sessions }) => {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { GOOSE_SERVE_EXITED_USER_MESSAGE } from '../../gooseServeLeaseRegistry';
|
||||
|
||||
const sdk = vi.hoisted(() => {
|
||||
const initialize = vi.fn();
|
||||
const instances: MockGooseClient[] = [];
|
||||
|
||||
class MockGooseClient {
|
||||
readonly initialize = initialize;
|
||||
readonly closed: Promise<void>;
|
||||
resolveClosed: () => void = () => undefined;
|
||||
|
||||
constructor() {
|
||||
this.closed = new Promise<void>((resolve) => {
|
||||
this.resolveClosed = resolve;
|
||||
});
|
||||
instances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
return { GooseClient: MockGooseClient, initialize, instances };
|
||||
});
|
||||
|
||||
const transport = vi.hoisted(() => ({
|
||||
createWebSocketStream: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@aaif/goose-sdk', () => ({
|
||||
DEFAULT_GOOSE_MCP_HOST_CAPABILITIES: {},
|
||||
GooseClient: sdk.GooseClient,
|
||||
}));
|
||||
|
||||
vi.mock('../createWebSocketStream', () => ({
|
||||
createWebSocketStream: transport.createWebSocketStream,
|
||||
}));
|
||||
|
||||
describe('ACP connection ownership', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.resetModules();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
sdk.initialize.mockReset().mockResolvedValue({});
|
||||
sdk.instances.length = 0;
|
||||
transport.createWebSocketStream.mockReset().mockImplementation(() => ({
|
||||
readable: {},
|
||||
writable: {},
|
||||
close: vi.fn(),
|
||||
}));
|
||||
window.electron.getAcpUrl = vi.fn().mockResolvedValue('ws://localhost/acp');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shares one initialization between concurrent callers', async () => {
|
||||
const { getAcpClient } = await import('../acpConnection');
|
||||
|
||||
const [first, second] = await Promise.all([getAcpClient(), getAcpClient()]);
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(sdk.instances).toHaveLength(1);
|
||||
expect(sdk.initialize).toHaveBeenCalledTimes(1);
|
||||
expect(transport.createWebSocketStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('automatically reconnects after close and shares the result between callers', async () => {
|
||||
const { getAcpClient } = await import('../acpConnection');
|
||||
const firstClient = await getAcpClient();
|
||||
const firstStream = transport.createWebSocketStream.mock.results[0].value;
|
||||
|
||||
sdk.instances[0].resolveClosed();
|
||||
await Promise.resolve();
|
||||
const firstCaller = getAcpClient();
|
||||
const secondCaller = getAcpClient();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(249);
|
||||
expect(sdk.instances).toHaveLength(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
const [firstResult, secondResult] = await Promise.all([firstCaller, secondCaller]);
|
||||
|
||||
expect(firstStream.close).toHaveBeenCalledOnce();
|
||||
expect(firstResult).toBe(secondResult);
|
||||
expect(firstResult).not.toBe(firstClient);
|
||||
expect(sdk.instances).toHaveLength(2);
|
||||
expect(transport.createWebSocketStream).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('increases the backoff after a failed reconnect attempt', async () => {
|
||||
sdk.initialize
|
||||
.mockResolvedValueOnce({})
|
||||
.mockRejectedValueOnce(new Error('server unavailable'))
|
||||
.mockResolvedValueOnce({});
|
||||
const { getAcpClient } = await import('../acpConnection');
|
||||
await getAcpClient();
|
||||
|
||||
sdk.instances[0].resolveClosed();
|
||||
await Promise.resolve();
|
||||
const reconnected = getAcpClient();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(sdk.instances).toHaveLength(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(499);
|
||||
expect(sdk.instances).toHaveLength(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await reconnected;
|
||||
|
||||
expect(sdk.instances).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('stops reconnecting when the Goose backend has exited', async () => {
|
||||
const { getAcpClient, subscribeToAcpRecovery } = await import('../acpConnection');
|
||||
const listener = vi.fn();
|
||||
subscribeToAcpRecovery(listener);
|
||||
await getAcpClient();
|
||||
|
||||
const getAcpUrl = vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new Error(`Error invoking remote method 'get-acp-url': ${GOOSE_SERVE_EXITED_USER_MESSAGE}`)
|
||||
);
|
||||
window.electron.getAcpUrl = getAcpUrl;
|
||||
sdk.instances[0].resolveClosed();
|
||||
await Promise.resolve();
|
||||
|
||||
const connection = expect(getAcpClient()).rejects.toThrow(GOOSE_SERVE_EXITED_USER_MESSAGE);
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
await connection;
|
||||
|
||||
expect(listener.mock.calls).toEqual([[true], [false]]);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(getAcpUrl).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('reconnects immediately after system resume', async () => {
|
||||
const { getAcpClient, reconnectAcpAfterSystemResume } = await import('../acpConnection');
|
||||
await getAcpClient();
|
||||
const firstStream = transport.createWebSocketStream.mock.results[0].value;
|
||||
|
||||
reconnectAcpAfterSystemResume();
|
||||
const reconnected = getAcpClient();
|
||||
await reconnected;
|
||||
|
||||
expect(firstStream.close).toHaveBeenCalledOnce();
|
||||
expect(sdk.instances).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does nothing on system resume before ACP has been used', async () => {
|
||||
const { reconnectAcpAfterSystemResume } = await import('../acpConnection');
|
||||
|
||||
reconnectAcpAfterSystemResume();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(sdk.instances).toHaveLength(0);
|
||||
expect(transport.createWebSocketStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses normal backoff when the immediate resume attempt fails', async () => {
|
||||
sdk.initialize
|
||||
.mockResolvedValueOnce({})
|
||||
.mockRejectedValueOnce(new Error('network is not ready'))
|
||||
.mockResolvedValueOnce({});
|
||||
const { getAcpClient, reconnectAcpAfterSystemResume } = await import('../acpConnection');
|
||||
await getAcpClient();
|
||||
|
||||
reconnectAcpAfterSystemResume();
|
||||
const reconnected = getAcpClient();
|
||||
await Promise.resolve();
|
||||
expect(sdk.instances).toHaveLength(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(249);
|
||||
expect(sdk.instances).toHaveLength(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await reconnected;
|
||||
|
||||
expect(sdk.instances).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('supersedes an older retry loop after system resume', async () => {
|
||||
const { getAcpClient, reconnectAcpAfterSystemResume } = await import('../acpConnection');
|
||||
await getAcpClient();
|
||||
|
||||
sdk.instances[0].resolveClosed();
|
||||
await Promise.resolve();
|
||||
reconnectAcpAfterSystemResume();
|
||||
await getAcpClient();
|
||||
expect(sdk.instances).toHaveLength(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(sdk.instances).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('notifies subscribers while reconnecting and after recovery', async () => {
|
||||
const { getAcpClient, subscribeToAcpRecovery } = await import('../acpConnection');
|
||||
const listener = vi.fn();
|
||||
subscribeToAcpRecovery(listener);
|
||||
await getAcpClient();
|
||||
|
||||
sdk.instances[0].resolveClosed();
|
||||
await Promise.resolve();
|
||||
expect(listener).toHaveBeenCalledWith(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
await getAcpClient();
|
||||
|
||||
expect(listener).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -168,6 +168,23 @@ describe('acpChatSessionController.loadSession', () => {
|
||||
loadedSession()
|
||||
);
|
||||
});
|
||||
|
||||
it('restores a cached session from the server', async () => {
|
||||
vi.mocked(acpChatSessionStore.getSnapshot).mockReturnValue({
|
||||
...snapshotWithActivePrompt(null),
|
||||
session: loadedSession(),
|
||||
});
|
||||
vi.mocked(isAcpSessionLoadInFlight).mockReturnValue(false);
|
||||
|
||||
await acpChatSessionController.restoreSession(SESSION_ID);
|
||||
|
||||
expect(acpChatSessionActions.startSessionLoad).toHaveBeenCalledWith(SESSION_ID);
|
||||
expect(acpLoadSession).toHaveBeenCalledWith(SESSION_ID);
|
||||
expect(acpChatSessionActions.finishSessionLoad).toHaveBeenCalledWith(
|
||||
SESSION_ID,
|
||||
loadedSession()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('acpChatSessionController.stop', () => {
|
||||
@@ -353,7 +370,10 @@ describe('acpChatSessionController.updateMessage', () => {
|
||||
resolvePromptCancellation!();
|
||||
await updatePromise;
|
||||
|
||||
expect(acpTruncateSessionConversation).toHaveBeenCalledWith(SESSION_ID, existingMessage.created);
|
||||
expect(acpTruncateSessionConversation).toHaveBeenCalledWith(
|
||||
SESSION_ID,
|
||||
existingMessage.created
|
||||
);
|
||||
expect(acpPromptSession).toHaveBeenCalled();
|
||||
expect(acpChatSessionActions.clearPromptCancellation).not.toHaveBeenCalledWith(
|
||||
SESSION_ID,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createWebSocketStream } from '../createWebSocketStream';
|
||||
|
||||
class FakeWebSocket extends window.EventTarget {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
readonly sent: string[] = [];
|
||||
readyState = FakeWebSocket.CONNECTING;
|
||||
|
||||
constructor(readonly url: string) {
|
||||
super();
|
||||
fakeWebSockets.push(this);
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.readyState = FakeWebSocket.OPEN;
|
||||
this.dispatchEvent(new Event('open'));
|
||||
}
|
||||
|
||||
send(message: string): void {
|
||||
this.sent.push(message);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.readyState === FakeWebSocket.CLOSED) {
|
||||
return;
|
||||
}
|
||||
this.readyState = FakeWebSocket.CLOSED;
|
||||
this.dispatchEvent(new Event('close'));
|
||||
}
|
||||
|
||||
fail(): void {
|
||||
this.dispatchEvent(new Event('error'));
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
const fakeWebSockets: FakeWebSocket[] = [];
|
||||
|
||||
function latestWebSocket(): FakeWebSocket {
|
||||
const ws = fakeWebSockets[fakeWebSockets.length - 1];
|
||||
if (!ws) {
|
||||
throw new Error('Expected a WebSocket to be created');
|
||||
}
|
||||
return ws;
|
||||
}
|
||||
|
||||
function testRequest() {
|
||||
return {
|
||||
jsonrpc: '2.0' as const,
|
||||
id: 1,
|
||||
method: 'test',
|
||||
};
|
||||
}
|
||||
|
||||
describe('createWebSocketStream', () => {
|
||||
beforeEach(() => {
|
||||
fakeWebSockets.length = 0;
|
||||
vi.stubGlobal('WebSocket', FakeWebSocket);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('waits for the socket to open before sending JSON', async () => {
|
||||
const stream = createWebSocketStream('ws://localhost/acp');
|
||||
const writer = stream.writable.getWriter();
|
||||
const write = writer.write(testRequest());
|
||||
const ws = latestWebSocket();
|
||||
|
||||
expect(ws.sent).toEqual([]);
|
||||
|
||||
ws.open();
|
||||
await write;
|
||||
|
||||
expect(ws.sent).toEqual(['{"jsonrpc":"2.0","id":1,"method":"test"}']);
|
||||
});
|
||||
|
||||
it('closes the readable stream when the socket closes', async () => {
|
||||
const stream = createWebSocketStream('ws://localhost/acp');
|
||||
const reader = stream.readable.getReader();
|
||||
const ws = latestWebSocket();
|
||||
|
||||
ws.open();
|
||||
ws.close();
|
||||
|
||||
await expect(reader.read()).resolves.toEqual({ done: true, value: undefined });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
event: 'closes',
|
||||
trigger: (ws: FakeWebSocket) => ws.close(),
|
||||
error: 'ACP WebSocket closed before connection opened',
|
||||
},
|
||||
{
|
||||
event: 'errors',
|
||||
trigger: (ws: FakeWebSocket) => ws.fail(),
|
||||
error: 'ACP WebSocket connection failed',
|
||||
},
|
||||
])(
|
||||
'rejects a pending write when the socket $event before opening',
|
||||
async ({ trigger, error }) => {
|
||||
const stream = createWebSocketStream('ws://localhost/acp');
|
||||
const writer = stream.writable.getWriter();
|
||||
const write = writer.write(testRequest());
|
||||
|
||||
trigger(latestWebSocket());
|
||||
|
||||
await expect(write).rejects.toThrow(error);
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects a write when the socket has closed', async () => {
|
||||
const stream = createWebSocketStream('ws://localhost/acp');
|
||||
const writer = stream.writable.getWriter();
|
||||
const ws = latestWebSocket();
|
||||
|
||||
ws.open();
|
||||
ws.close();
|
||||
|
||||
await expect(writer.write(testRequest())).rejects.toThrow('ACP WebSocket connection lost');
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@aaif/goose-sdk';
|
||||
import { PROTOCOL_VERSION, type InitializeResponse } from '@agentclientprotocol/sdk';
|
||||
import packageJson from '../../package.json';
|
||||
import { GOOSE_SERVE_EXITED_USER_MESSAGE } from '../gooseServeLeaseRegistry';
|
||||
import {
|
||||
handleAcpGooseSessionNotification,
|
||||
handleAcpSessionNotification,
|
||||
@@ -14,54 +15,115 @@ import { requestAcpElicitation } from './elicitationRequests';
|
||||
import { requestAcpPermission } from './permissionRequests';
|
||||
import { requestAcpRecipeParams } from './recipeParamRequests';
|
||||
|
||||
type InitializedAcpClient = {
|
||||
type AcpConnection = {
|
||||
client: GooseClient;
|
||||
stream: ReturnType<typeof createWebSocketStream>;
|
||||
initializeResponse: InitializeResponse;
|
||||
};
|
||||
|
||||
type AcpRecoveryListener = (recovering: boolean) => void;
|
||||
|
||||
const ACP_INITIALIZE_TIMEOUT_MS = 10_000;
|
||||
const ACP_RECONNECT_BASE_DELAY_MS = 500;
|
||||
const ACP_RECONNECT_MAX_DELAY_MS = 30_000;
|
||||
|
||||
let clientPromise: Promise<InitializedAcpClient> | null = null;
|
||||
let resolvedClient: InitializedAcpClient | null = null;
|
||||
let currentConnection: AcpConnection | null = null;
|
||||
let pendingConnection: Promise<AcpConnection> | null = null;
|
||||
let connectionGeneration = 0;
|
||||
let recovering = false;
|
||||
const recoveryListeners = new Set<AcpRecoveryListener>();
|
||||
|
||||
function createClientCallbacks(): () => GooseClientCallbacks {
|
||||
return () => ({
|
||||
requestPermission: requestAcpPermission,
|
||||
unstable_createElicitation: requestAcpElicitation,
|
||||
unstable_sessionRecipeRequestParams: requestAcpRecipeParams,
|
||||
sessionUpdate: handleAcpSessionNotification,
|
||||
unstable_sessionUpdate: handleAcpGooseSessionNotification,
|
||||
});
|
||||
export async function getAcpClient(): Promise<GooseClient> {
|
||||
return (await getConnection()).client;
|
||||
}
|
||||
|
||||
function monitorConnection(client: GooseClient): void {
|
||||
client.closed
|
||||
.then(() => {
|
||||
resolvedClient = null;
|
||||
clientPromise = null;
|
||||
})
|
||||
.catch(() => {
|
||||
resolvedClient = null;
|
||||
clientPromise = null;
|
||||
});
|
||||
export async function getAcpInitializeResponse(): Promise<InitializeResponse> {
|
||||
return (await getConnection()).initializeResponse;
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeout = new Promise<T>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
});
|
||||
export function reconnectAcpAfterSystemResume(): void {
|
||||
recoverConnection(true);
|
||||
}
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
export function isAcpRecovering(): boolean {
|
||||
return recovering;
|
||||
}
|
||||
|
||||
export function subscribeToAcpRecovery(listener: AcpRecoveryListener): () => void {
|
||||
recoveryListeners.add(listener);
|
||||
return () => {
|
||||
recoveryListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function setRecovering(nextRecovering: boolean): void {
|
||||
if (recovering === nextRecovering) {
|
||||
return;
|
||||
}
|
||||
|
||||
recovering = nextRecovering;
|
||||
for (const listener of recoveryListeners) {
|
||||
listener(recovering);
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeConnection(): Promise<InitializedAcpClient> {
|
||||
function recoverConnection(immediate: boolean): void {
|
||||
if (!currentConnection && !pendingConnection) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRecovering(true);
|
||||
const previousConnection = currentConnection;
|
||||
connectionGeneration += 1;
|
||||
currentConnection = null;
|
||||
pendingConnection = null;
|
||||
previousConnection?.stream.close();
|
||||
|
||||
const generation = connectionGeneration;
|
||||
const recoveryAttempt = immediate
|
||||
? openConnection(generation).catch((error) => {
|
||||
if (generation !== connectionGeneration || isGooseServeExitedError(error)) {
|
||||
throw error;
|
||||
}
|
||||
return retryWithBackoff(generation);
|
||||
})
|
||||
: retryWithBackoff(generation);
|
||||
pendingConnection = recoveryAttempt;
|
||||
void recoveryAttempt.then(
|
||||
() => {
|
||||
if (generation === connectionGeneration) {
|
||||
setRecovering(false);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (generation === connectionGeneration) {
|
||||
setRecovering(false);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function getConnection(): Promise<AcpConnection> {
|
||||
if (currentConnection) {
|
||||
return currentConnection;
|
||||
}
|
||||
|
||||
if (!pendingConnection) {
|
||||
const generation = connectionGeneration;
|
||||
let connectionAttempt: Promise<AcpConnection>;
|
||||
connectionAttempt = openConnection(generation).catch((error) => {
|
||||
if (pendingConnection === connectionAttempt) {
|
||||
pendingConnection = null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
pendingConnection = connectionAttempt;
|
||||
}
|
||||
|
||||
return pendingConnection;
|
||||
}
|
||||
|
||||
async function openConnection(generation: number): Promise<AcpConnection> {
|
||||
const wsUrl = await window.electron.getAcpUrl();
|
||||
if (!wsUrl) {
|
||||
throw new Error('ACP URL is not available');
|
||||
@@ -96,46 +158,78 @@ async function initializeConnection(): Promise<InitializedAcpClient> {
|
||||
`ACP initialize timed out after ${ACP_INITIALIZE_TIMEOUT_MS}ms`
|
||||
);
|
||||
|
||||
monitorConnection(client);
|
||||
return { client, initializeResponse };
|
||||
if (generation !== connectionGeneration) {
|
||||
throw new Error('ACP connection attempt is no longer current');
|
||||
}
|
||||
|
||||
const connection = { client, stream, initializeResponse };
|
||||
currentConnection = connection;
|
||||
const handleClose = () => {
|
||||
if (currentConnection === connection) {
|
||||
recoverConnection(false);
|
||||
}
|
||||
};
|
||||
connection.client.closed.then(handleClose, handleClose);
|
||||
return connection;
|
||||
} catch (error) {
|
||||
stream.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAcpClient(): Promise<GooseClient> {
|
||||
return (await getInitializedAcpClient()).client;
|
||||
}
|
||||
async function retryWithBackoff(generation: number): Promise<AcpConnection> {
|
||||
for (let attempt = 0; generation === connectionGeneration; attempt += 1) {
|
||||
const maximumDelay = Math.min(
|
||||
ACP_RECONNECT_MAX_DELAY_MS,
|
||||
ACP_RECONNECT_BASE_DELAY_MS * 2 ** attempt
|
||||
);
|
||||
await delay(Math.floor(Math.random() * maximumDelay));
|
||||
|
||||
export function getAcpClientSync(): GooseClient | null {
|
||||
return resolvedClient?.client ?? null;
|
||||
}
|
||||
if (generation !== connectionGeneration) {
|
||||
break;
|
||||
}
|
||||
|
||||
export async function getAcpInitializeResponse(): Promise<InitializeResponse> {
|
||||
return (await getInitializedAcpClient()).initializeResponse;
|
||||
}
|
||||
|
||||
export function isAcpClientReady(): boolean {
|
||||
return resolvedClient !== null;
|
||||
}
|
||||
|
||||
async function getInitializedAcpClient(): Promise<InitializedAcpClient> {
|
||||
if (resolvedClient) {
|
||||
return resolvedClient;
|
||||
}
|
||||
|
||||
if (!clientPromise) {
|
||||
clientPromise = initializeConnection()
|
||||
.then((clientState) => {
|
||||
resolvedClient = clientState;
|
||||
return clientState;
|
||||
})
|
||||
.catch((error) => {
|
||||
clientPromise = null;
|
||||
try {
|
||||
return await openConnection(generation);
|
||||
} catch (error) {
|
||||
if (generation !== connectionGeneration || isGooseServeExitedError(error)) {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return clientPromise;
|
||||
throw new Error('ACP connection attempt is no longer current');
|
||||
}
|
||||
|
||||
function isGooseServeExitedError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.includes(GOOSE_SERVE_EXITED_USER_MESSAGE);
|
||||
}
|
||||
|
||||
function delay(delayMs: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
|
||||
function createClientCallbacks(): () => GooseClientCallbacks {
|
||||
return () => ({
|
||||
requestPermission: requestAcpPermission,
|
||||
unstable_createElicitation: requestAcpElicitation,
|
||||
unstable_sessionRecipeRequestParams: requestAcpRecipeParams,
|
||||
sessionUpdate: handleAcpSessionNotification,
|
||||
unstable_sessionUpdate: handleAcpGooseSessionNotification,
|
||||
});
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeout = new Promise<T>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,7 @@ import { ChatState } from '../types/chatState';
|
||||
import type { Session } from '../types/session';
|
||||
import { errorMessage } from '../utils/conversionUtils';
|
||||
import { showExtensionLoadResults } from '../utils/extensionErrorUtils';
|
||||
import {
|
||||
createUserMessage,
|
||||
getPendingToolConfirmationIds,
|
||||
type Message,
|
||||
} from '../types/message';
|
||||
import { createUserMessage, getPendingToolConfirmationIds, type Message } from '../types/message';
|
||||
import {
|
||||
acpChatSessionActions,
|
||||
acpChatSessionStore,
|
||||
@@ -48,6 +44,7 @@ export interface AcpChatSessionController {
|
||||
recipe?: AcpRecipeOptions
|
||||
): Promise<Session>;
|
||||
loadSession(sessionId: string, options?: AcpLoadSessionOptions): Promise<void>;
|
||||
restoreSession(sessionId: string): Promise<void>;
|
||||
submitMessage(
|
||||
sessionId: string,
|
||||
userMessage: Message,
|
||||
@@ -131,6 +128,17 @@ async function loadSession(sessionId: string, options: AcpLoadSessionOptions = {
|
||||
return;
|
||||
}
|
||||
|
||||
await loadSessionFromServer(sessionId, options);
|
||||
}
|
||||
|
||||
async function restoreSession(sessionId: string): Promise<void> {
|
||||
await loadSessionFromServer(sessionId);
|
||||
}
|
||||
|
||||
async function loadSessionFromServer(
|
||||
sessionId: string,
|
||||
options: AcpLoadSessionOptions = {}
|
||||
): Promise<void> {
|
||||
if (!isAcpSessionLoadInFlight(sessionId)) {
|
||||
acpChatSessionActions.startSessionLoad(sessionId);
|
||||
}
|
||||
@@ -316,6 +324,7 @@ async function updateMessage(
|
||||
export const acpChatSessionController: AcpChatSessionController = {
|
||||
createSession,
|
||||
loadSession,
|
||||
restoreSession,
|
||||
submitMessage,
|
||||
stop,
|
||||
updateMessage,
|
||||
|
||||
@@ -28,6 +28,11 @@ export function createWebSocketStream(wsUrl: string): ClosableAcpStream {
|
||||
ws.addEventListener('error', () => reject(new Error('ACP WebSocket connection failed')), {
|
||||
once: true,
|
||||
});
|
||||
ws.addEventListener(
|
||||
'close',
|
||||
() => reject(new Error('ACP WebSocket closed before connection opened')),
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
@@ -67,6 +72,9 @@ export function createWebSocketStream(wsUrl: string): ClosableAcpStream {
|
||||
const writable = new window.WritableStream({
|
||||
async write(message) {
|
||||
await openPromise;
|
||||
if (closed || ws.readyState !== window.WebSocket.OPEN) {
|
||||
throw new Error('ACP WebSocket connection lost');
|
||||
}
|
||||
ws.send(JSON.stringify(message));
|
||||
},
|
||||
close() {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('useAutoSubmit', () => {
|
||||
expect(dispatchEventSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('auto-submits once recipe acceptance is confirmed', () => {
|
||||
it('keeps the initial message while blocked and submits it once unblocked', () => {
|
||||
const handleSubmit = vi.fn();
|
||||
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
|
||||
|
||||
@@ -82,6 +82,7 @@ describe('useAutoSubmit', () => {
|
||||
);
|
||||
|
||||
expect(handleSubmit).not.toHaveBeenCalled();
|
||||
expect(dispatchEventSpy).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ canAutoSubmit: true });
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
useAcpChatSessionSnapshot,
|
||||
} from '../acp/chatSessionStore';
|
||||
import { acpSteerSession } from '../acp/prompt';
|
||||
import { isAcpRecovering } from '../acp/acpConnection';
|
||||
|
||||
const initialTokenState: TokenState = {
|
||||
inputTokens: 0,
|
||||
@@ -150,6 +151,10 @@ export function useChatSession({
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (input: UserInput) => {
|
||||
if (isAcpRecovering()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { msg: userMessage, images } = input;
|
||||
const currentSnapshot = getCurrentSnapshot();
|
||||
|
||||
|
||||
@@ -131,6 +131,9 @@
|
||||
"baseChat.goHome": {
|
||||
"defaultMessage": "Zur Startseite"
|
||||
},
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "Verbindung unterbrochen. Verbindung wird wiederhergestellt…"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Erweiterung aktualisiert"
|
||||
},
|
||||
|
||||
@@ -131,6 +131,9 @@
|
||||
"baseChat.goHome": {
|
||||
"defaultMessage": "Go home"
|
||||
},
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "Connection lost. Reconnecting…"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Extension Updated"
|
||||
},
|
||||
|
||||
@@ -131,6 +131,9 @@
|
||||
"baseChat.goHome": {
|
||||
"defaultMessage": "Ir al inicio"
|
||||
},
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "Conexión perdida. Reconectando…"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Extensión actualizada"
|
||||
},
|
||||
|
||||
@@ -131,6 +131,9 @@
|
||||
"baseChat.goHome": {
|
||||
"defaultMessage": "Aller à l'accueil"
|
||||
},
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "Connexion perdue. Reconnexion…"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Extension mise à jour"
|
||||
},
|
||||
|
||||
@@ -131,6 +131,9 @@
|
||||
"baseChat.goHome": {
|
||||
"defaultMessage": "घर जाओ"
|
||||
},
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "कनेक्शन टूट गया। फिर से कनेक्ट किया जा रहा है…"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "एक्सटेंशन अपडेट किया गया"
|
||||
},
|
||||
|
||||
@@ -131,6 +131,9 @@
|
||||
"baseChat.goHome": {
|
||||
"defaultMessage": "Ke beranda"
|
||||
},
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "Koneksi terputus. Menghubungkan kembali…"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Ekstensi Diperbarui"
|
||||
},
|
||||
|
||||
@@ -131,6 +131,9 @@
|
||||
"baseChat.goHome": {
|
||||
"defaultMessage": "Vai alla home"
|
||||
},
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "Connessione persa. Riconnessione…"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Estensione aggiornata"
|
||||
},
|
||||
|
||||
@@ -131,6 +131,9 @@
|
||||
"baseChat.goHome": {
|
||||
"defaultMessage": "ホームへ"
|
||||
},
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "接続が失われました。再接続しています…"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "拡張機能を更新しました"
|
||||
},
|
||||
|
||||
@@ -131,6 +131,9 @@
|
||||
"baseChat.goHome": {
|
||||
"defaultMessage": "홈으로 이동"
|
||||
},
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "연결이 끊어졌습니다. 다시 연결하는 중…"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "익스텐션이 업데이트되었습니다."
|
||||
},
|
||||
|
||||
@@ -131,6 +131,9 @@
|
||||
"baseChat.goHome": {
|
||||
"defaultMessage": "Pergi ke laman utama"
|
||||
},
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "Sambungan terputus. Menyambung semula…"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Sambungan Dikemas Kini"
|
||||
},
|
||||
|
||||
@@ -131,6 +131,9 @@
|
||||
"baseChat.goHome": {
|
||||
"defaultMessage": "Ir para o início"
|
||||
},
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "Conexão perdida. Reconectando…"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Extensão atualizada"
|
||||
},
|
||||
|
||||
@@ -131,6 +131,9 @@
|
||||
"baseChat.goHome": {
|
||||
"defaultMessage": "На главную"
|
||||
},
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "Соединение потеряно. Повторное подключение…"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Расширение обновлено"
|
||||
},
|
||||
|
||||
@@ -131,6 +131,9 @@
|
||||
"baseChat.goHome": {
|
||||
"defaultMessage": "Ana sayfaya git"
|
||||
},
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "Bağlantı kesildi. Yeniden bağlanılıyor…"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Uzantı Güncellendi"
|
||||
},
|
||||
|
||||
@@ -131,6 +131,9 @@
|
||||
"baseChat.goHome": {
|
||||
"defaultMessage": "Về trang chủ"
|
||||
},
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "Mất kết nối. Đang kết nối lại…"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "Đã cập nhật tiện ích mở rộng"
|
||||
},
|
||||
|
||||
@@ -131,6 +131,9 @@
|
||||
"baseChat.goHome": {
|
||||
"defaultMessage": "回到首页"
|
||||
},
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "连接已断开。正在重新连接…"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "扩展已更新"
|
||||
},
|
||||
|
||||
@@ -131,6 +131,9 @@
|
||||
"baseChat.goHome": {
|
||||
"defaultMessage": "回到首頁"
|
||||
},
|
||||
"baseChat.reconnecting": {
|
||||
"defaultMessage": "連線已中斷。正在重新連線…"
|
||||
},
|
||||
"bottomMenuExtensionSelection.extensionUpdated": {
|
||||
"defaultMessage": "擴充功能已更新"
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
MenuItem,
|
||||
net,
|
||||
Notification,
|
||||
powerMonitor,
|
||||
powerSaveBlocker,
|
||||
screen,
|
||||
session,
|
||||
@@ -2408,6 +2409,14 @@ const registerGlobalShortcuts = () => {
|
||||
};
|
||||
|
||||
async function appMain() {
|
||||
powerMonitor.on('resume', () => {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.send('system-resume');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await configureProxy();
|
||||
|
||||
// Ensure Windows shims are available before any MCP processes are spawned
|
||||
|
||||
Reference in New Issue
Block a user