diff --git a/app/desktop/src/__tests__/App.v4.test.tsx b/app/desktop/src/__tests__/App.v4.test.tsx
index dfb8dc186..273773e29 100644
--- a/app/desktop/src/__tests__/App.v4.test.tsx
+++ b/app/desktop/src/__tests__/App.v4.test.tsx
@@ -572,9 +572,6 @@ describe('Desktop App v4 root', () => {
});
});
expect(mockedUseDeviceRegistration).toHaveBeenCalledWith(mockHubClient);
- await waitFor(() => {
- expect(mockedQueryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['execution-targets'] });
- });
expect(mockedUseHubEventStream).toHaveBeenCalledWith(getAccessToken);
expect(mockedCreateHubClient).toHaveBeenCalledWith({ getToken: getAccessToken });
expect(tryAutoLogin).not.toHaveBeenCalled();
diff --git a/app/desktop/src/components/DesktopHubTaskBridge.test.tsx b/app/desktop/src/components/DesktopHubTaskBridge.test.tsx
new file mode 100644
index 000000000..05a0e3108
--- /dev/null
+++ b/app/desktop/src/components/DesktopHubTaskBridge.test.tsx
@@ -0,0 +1,92 @@
+import React from 'react';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { cleanup, render, renderHook, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { hubQueryKeys } from '@shared/stores/queryKeys';
+import { queryClient } from '@/api/queryClient';
+import { useHubExecutionTargets } from '@/api/executionTargetQueries';
+import DesktopHubTaskBridge from './DesktopHubTaskBridge';
+
+const fixture = vi.hoisted(() => ({
+ registration: { status: 'registered', deviceId: 'fixture-device' },
+ listExecutionTargets: vi.fn(),
+ auth: { isAuthenticated: true, token: 'fixture-token', tryAutoLogin: vi.fn() },
+}));
+
+vi.mock('@/api/hubClient', () => ({
+ createHubClient: () => ({ listExecutionTargets: fixture.listExecutionTargets }),
+}));
+vi.mock('@/hooks/useAuth', () => ({
+ getAccessToken: () => fixture.auth.token,
+ useAuth: () => fixture.auth,
+}));
+vi.mock('@/hooks/useDeviceRegistration', () => ({
+ useDeviceRegistration: () => fixture.registration,
+}));
+vi.mock('@/hooks/useHubEventStream', () => ({
+ useHubEventStream: () => ({ status: 'disconnected', hubWS: null }),
+}));
+vi.mock('@/hooks/useHubIntegration', () => ({ useHubIntegration: vi.fn() }));
+vi.mock('@/hooks/useHealth', () => ({ useHealth: () => ({ online: false }) }));
+vi.mock('@/api/agentQueries', () => ({ useAgentList: () => ({ data: undefined }) }));
+vi.mock('@/api/modelCatalogQueries', () => ({ useModelCatalog: () => ({ data: undefined }) }));
+vi.mock('@/config', () => ({ getEdgeBaseUrl: () => 'http://edge.test' }));
+
+function Wrapper({ children }: React.PropsWithChildren) {
+ return {children};
+}
+
+async function seedLiveTargets() {
+ // The production hook supplies the cache key; do not prove a made-up key
+ // invalidates itself. A fresh entry also prevents mount-refetch hiding a miss.
+ const hook = renderHook(() => useHubExecutionTargets({ enabled: true }), { wrapper: Wrapper });
+ await waitFor(() => expect(hook.result.current.isSuccess).toBe(true));
+ const entry = queryClient.getQueryCache().getAll()[0];
+ if (!entry) throw new Error('The production target hook registered no cache entry');
+ hook.unmount();
+ expect(fixture.listExecutionTargets).toHaveBeenCalledTimes(1);
+ return entry.queryKey;
+}
+
+beforeEach(() => {
+ queryClient.clear();
+ queryClient.setDefaultOptions({ queries: { retry: false, staleTime: Infinity } });
+ vi.clearAllMocks();
+ fixture.registration.status = 'registered';
+ fixture.listExecutionTargets.mockResolvedValue({ items: [], page: { hasMore: false } });
+});
+
+afterEach(() => {
+ cleanup();
+ queryClient.clear();
+});
+
+describe('DesktopHubTaskBridge target refresh', () => {
+ it('refetches the live target cache after registration without invalidating unrelated Hub data', async () => {
+ const targetKey = await seedLiveTargets();
+ queryClient.setQueryData(hubQueryKeys.contacts.list, []);
+ fixture.listExecutionTargets.mockResolvedValue({
+ items: [{ id: 'new-target', name: 'New target', target_type: 'local_edge', health_state: 'offline' }],
+ page: { hasMore: false },
+ });
+
+ render(, { wrapper: Wrapper });
+
+ // This must happen before the hook's 10-second fallback poll.
+ await waitFor(() => expect(queryClient.getQueryData(targetKey)).toMatchObject({
+ items: [{ id: 'new-target' }],
+ }));
+ expect(fixture.listExecutionTargets).toHaveBeenCalledTimes(2);
+ expect(queryClient.getQueryState(hubQueryKeys.contacts.list)?.isInvalidated).toBe(false);
+ });
+
+ it('does not invalidate or refetch targets while registration is pending', async () => {
+ const targetKey = await seedLiveTargets();
+ fixture.registration.status = 'registering';
+
+ render(, { wrapper: Wrapper });
+
+ expect(queryClient.getQueryState(targetKey)?.isInvalidated).toBe(false);
+ expect(fixture.listExecutionTargets).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/app/desktop/src/components/DesktopHubTaskBridge.tsx b/app/desktop/src/components/DesktopHubTaskBridge.tsx
index 85bd563ce..85765cacc 100644
--- a/app/desktop/src/components/DesktopHubTaskBridge.tsx
+++ b/app/desktop/src/components/DesktopHubTaskBridge.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef } from 'react';
import { queryClient } from '@/api/queryClient';
+import { hubQueryKeys } from '@shared/stores/queryKeys';
import { getAccessToken, useAuth } from '@/hooks/useAuth';
import { createHubClient } from '@/api/hubClient';
import {
@@ -83,7 +84,7 @@ function DesktopHubTaskBridgeActive() {
useEffect(() => {
if (!deviceReady) return;
- void queryClient.invalidateQueries({ queryKey: ['execution-targets'] });
+ void queryClient.invalidateQueries({ queryKey: hubQueryKeys.executionTargets.root });
}, [deviceReady, deviceRegistration.deviceId]);
// Mirror live connection health into the connection store so the shell can