diff --git a/apps/desktop/src/app/capabilities/connectors/data/account-operations.ts b/apps/desktop/src/app/capabilities/connectors/data/account-operations.ts
index 4e7bdbc8ea..7351a1b464 100644
--- a/apps/desktop/src/app/capabilities/connectors/data/account-operations.ts
+++ b/apps/desktop/src/app/capabilities/connectors/data/account-operations.ts
@@ -8,6 +8,7 @@ import { atom } from 'nanostores'
import type { ProfileScope } from '@/hermes'
import { type ConnectionTarget, parseConnectionTarget } from '@/store/connection-request'
+import { noteFreeTierAppConnected } from '@/store/free-tier-sign-in'
import { activeGatewayConnectionId } from '@/store/gateway'
import { normalizeProfileKey } from '@/store/profile'
@@ -212,5 +213,11 @@ function refetchOnSettle(operation: AccountOperation): void {
return
}
- invalidateConnectors(operation.scope, 'list', 'accounts')
+ // The first connect can create the identity the page opened without, so its bundled catalog and
+ // empty rules are re-read too.
+ invalidateConnectors(operation.scope, 'list', 'accounts', 'catalog', 'policy')
+
+ if (operation.targets.some(target => target.state === 'connected')) {
+ noteFreeTierAppConnected()
+ }
}
diff --git a/apps/desktop/src/app/capabilities/connectors/data/queries-rules.test.tsx b/apps/desktop/src/app/capabilities/connectors/data/queries-rules.test.tsx
new file mode 100644
index 0000000000..543d3af759
--- /dev/null
+++ b/apps/desktop/src/app/capabilities/connectors/data/queries-rules.test.tsx
@@ -0,0 +1,82 @@
+import { QueryClientProvider } from '@tanstack/react-query'
+import { cleanup, renderHook, waitFor } from '@testing-library/react'
+import type { ReactNode } from 'react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { queryClient } from '@/lib/query-client'
+import { noteFreeTierTurnComplete } from '@/store/free-tier-sign-in'
+import { setPrimaryGateway, setPrimaryGatewayConnectionId } from '@/store/gateway'
+
+import { useHostedConnectors } from './queries'
+
+const row = { connected: false, connector: 'notion', enabled: true, gateway_disabled_tools: [] }
+
+function gateway(listed: object[]) {
+ const request = vi.fn(
+ async (method: string) =>
+ ({
+ 'connectors.accounts': { accounts: [] },
+ 'connectors.catalog': { connectors: [] },
+ 'connectors.list': { available: true, connectors: [...listed] },
+ 'connectors.policy.get': { effective: { mode: 'unrestricted' }, layers: [] }
+ })[method]
+ )
+
+ // SAFETY: the page's queries call only `request` on the primary socket.
+ setPrimaryGateway({ request } as never)
+ setPrimaryGatewayConnectionId('connection-1')
+
+ return request
+}
+
+// The page invalidates through the app's one query client, so the hook runs on it too.
+function mount() {
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ )
+
+ return renderHook(() => useHostedConnectors('default'), { wrapper })
+}
+
+const asked = (request: ReturnType, method: string) =>
+ request.mock.calls.some(([name]) => name === method)
+
+afterEach(() => {
+ cleanup()
+ queryClient.clear()
+})
+
+describe('connector rules on the Connectors page', () => {
+ it('are not asked for while the list is empty (no identity yet)', async () => {
+ const request = gateway([])
+ const { result } = mount()
+
+ await waitFor(() => expect(result.current.phase).toBe('ready'))
+ await waitFor(() => expect(asked(request, 'connectors.catalog')).toBe(true))
+
+ expect(asked(request, 'connectors.policy.get')).toBe(false)
+ expect(result.current.rulesFailed).toBe(false)
+ })
+
+ it('are asked for once the gateway lists apps', async () => {
+ const request = gateway([row])
+
+ mount()
+
+ await waitFor(() => expect(asked(request, 'connectors.policy.get')).toBe(true))
+ })
+
+ it('are asked for once an identity appears outside the page (an agent connect in a chat)', async () => {
+ const listed: object[] = []
+ const request = gateway(listed)
+ const { result } = mount()
+
+ await waitFor(() => expect(result.current.phase).toBe('ready'))
+ listed.push(row)
+ noteFreeTierTurnComplete()
+
+ await waitFor(() => expect(asked(request, 'connectors.policy.get')).toBe(true))
+ // The accounts read cached while there was no identity is re-read too, or the new account stays hidden.
+ expect(request.mock.calls.filter(([name]) => name === 'connectors.accounts')).toHaveLength(2)
+ })
+})
diff --git a/apps/desktop/src/app/capabilities/connectors/data/queries.ts b/apps/desktop/src/app/capabilities/connectors/data/queries.ts
index 593747b4cb..0355e7c3f1 100644
--- a/apps/desktop/src/app/capabilities/connectors/data/queries.ts
+++ b/apps/desktop/src/app/capabilities/connectors/data/queries.ts
@@ -7,6 +7,7 @@ import type { ProfileScope } from '@/hermes'
import { translateNow } from '@/i18n'
import { isMissingRpcMethod, isOutOfSyncRpcParams } from '@/lib/gateway-rpc'
import { queryClient } from '@/lib/query-client'
+import { $freeTierTurnCompleted } from '@/store/free-tier-sign-in'
import { notifyError } from '@/store/notifications'
import { hostedPhase } from '../derive'
@@ -102,8 +103,23 @@ export function useHostedConnectors(scope: ProfileScope): HostedConnectorsView {
queryKey: connectorsAccountsQueryKey(scope)
})
+ // Rules exist only for an identity. The page list is empty until one does (no token, no gateway
+ // read), so rules are asked for once it has rows.
+ const listed = (list.data?.connectors.length ?? 0) > 0
+
+ // The identity can appear outside this page (an agent connect in a chat). Until the list has rows,
+ // every finished turn or connect re-reads the list, catalog and accounts, which then move to the signed-in routes.
+ useEffect(
+ () =>
+ listed
+ ? undefined
+ : $freeTierTurnCompleted.listen(() => invalidateConnectors(scope, 'list', 'catalog', 'accounts')),
+ [listed, scope]
+ )
+
const policy = useQuery({
...read('policy'),
+ enabled: listed,
queryFn: () => connectorPolicy(scope),
queryKey: connectorsPolicyQueryKey(scope)
})
diff --git a/apps/desktop/src/app/contrib/hooks/desktop-onboarding-metrics.test.ts b/apps/desktop/src/app/contrib/hooks/desktop-onboarding-metrics.test.ts
index 65f981f460..0fb01f4c83 100644
--- a/apps/desktop/src/app/contrib/hooks/desktop-onboarding-metrics.test.ts
+++ b/apps/desktop/src/app/contrib/hooks/desktop-onboarding-metrics.test.ts
@@ -30,7 +30,7 @@ async function walk(...states: FreeTierSignInState[]): Promise {
}
const CLOSED: FreeTierSignInState = { status: 'closed' }
-const OFFER: FreeTierSignInState = { status: 'offer' }
+const OFFER: FreeTierSignInState = { kind: 'trial', status: 'offer' }
const SETTING_UP: FreeTierSignInState = { minting: false, status: 'setting_up' }
beforeEach(() => {
diff --git a/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx b/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx
index ab598eec74..75f84794ee 100644
--- a/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx
+++ b/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx
@@ -185,14 +185,14 @@ describe('FreeTierSignInDialog', () => {
$freeTierStatus.set({ ...dueNow, nudge_due_in: 0 })
})
- expect(requestGateway).not.toHaveBeenCalledWith('free_tier.claim_nudge')
+ expect(requestGateway).not.toHaveBeenCalledWith('free_tier.claim_nudge', undefined)
await act(async () => {
setOnboardingSurfaceActive('onboarding', false)
})
await waitFor(() => expect(screen.getByText('Keep going with Hermes')).toBeTruthy())
- expect(requestGateway).toHaveBeenCalledWith('free_tier.claim_nudge')
+ expect(requestGateway).toHaveBeenCalledWith('free_tier.claim_nudge', undefined)
})
it('a finished free-tier turn re-reads the status, and a due offer opens with no user action', async () => {
@@ -212,7 +212,7 @@ describe('FreeTierSignInDialog', () => {
it('Sign in continues into the normal sign-in flow', async () => {
await renderDialog()
- await act(async () => $freeTierSignIn.set({ status: 'offer' }))
+ await act(async () => $freeTierSignIn.set({ kind: 'trial', status: 'offer' }))
await act(async () => screen.getByRole('button', { name: 'Sign in' }).click())
@@ -221,12 +221,27 @@ describe('FreeTierSignInDialog', () => {
it('Not now closes the offer', async () => {
await renderDialog()
- await act(async () => $freeTierSignIn.set({ status: 'offer' }))
+ await act(async () => $freeTierSignIn.set({ kind: 'trial', status: 'offer' }))
await act(async () => screen.getByRole('button', { name: 'Not now' }).click())
expect($freeTierSignIn.get()).toEqual({ status: 'closed' })
expect(screen.queryByText('Keep going with Hermes')).toBeNull()
})
+
+ it('an app connected without the trial: claims the connector offer and shows its copy', async () => {
+ await renderDialog()
+ const ownKey = { ...dueNow, available: false, connector_nudge_due_in: null }
+ await act(async () => $freeTierStatus.set(ownKey))
+ requestGateway.mockImplementation(async (method?: string) =>
+ method === 'free_tier.claim_nudge' ? { claimed: true } : { ...ownKey, connector_nudge_due_in: 0 }
+ )
+
+ await act(async () => noteFreeTierTurnComplete())
+
+ await waitFor(() => expect(screen.getByText('Keep your connected apps')).toBeTruthy())
+ expect(requestGateway).toHaveBeenCalledWith('free_tier.claim_nudge', { kind: 'connector' })
+ expect(screen.queryByText('Keep going with Hermes')).toBeNull()
+ })
})
})
diff --git a/apps/desktop/src/components/free-tier/sign-in-dialog.tsx b/apps/desktop/src/components/free-tier/sign-in-dialog.tsx
index 8524abb288..7bd9537d03 100644
--- a/apps/desktop/src/components/free-tier/sign-in-dialog.tsx
+++ b/apps/desktop/src/components/free-tier/sign-in-dialog.tsx
@@ -145,7 +145,10 @@ export function FreeTierSignInDialog({ onSelectModel }: FreeTierSignInDialogProp