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 !open && closeFreeTierSignIn()} open> {state.status === 'offer' && ( - +