From ed3f864defda9e2fc32b6940a97bc38edff2243e Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Thu, 28 May 2026 16:34:12 +0300 Subject: [PATCH 1/9] fix(novu): Add confirmation step before Slack OAuth in connect CLI fixes NV-7897 (#11333) Co-authored-by: Cursor Agent --- apps/api/src/app/agents/agents.controller.ts | 1 + .../e2e/verify-managed-credentials.e2e.ts | 13 + .../managed-agent-generation.schema.ts | 21 +- apps/dashboard/src/pages/cli-auth.tsx | 207 +++- packages/novu/package.json | 2 +- .../src/commands/connect/api/integrations.ts | 46 + .../src/commands/connect/dashboard-urls.ts | 42 + .../connect/pipeline/channels/slack.ts | 23 +- .../resolve-agent-runtime-integration.ts | 227 ++++ .../src/commands/connect/pipeline/runner.ts | 120 ++- packages/novu/src/commands/connect/types.ts | 19 + .../commands/connect/ui/agent-spec-labels.ts | 103 ++ packages/novu/src/commands/connect/ui/app.tsx | 990 +----------------- .../novu/src/commands/connect/ui/index.tsx | 57 +- .../src/commands/connect/ui/logging-ui.ts | 124 ++- .../commands/connect/ui/orb/orb-renderer.tsx | 301 ++++++ .../src/commands/connect/ui/orb/orb-tint.ts | 130 +++ .../connect/ui/orb/use-preview-orb-morph.ts | 47 + .../src/commands/connect/ui/phase-content.tsx | 697 ++++++++++++ .../connect/ui/preview-field-config.ts | 143 +++ .../connect/ui/preview-generated-content.tsx | 388 +++++++ .../novu/src/commands/connect/ui/store.ts | 53 +- packages/novu/src/commands/connect/ui/ui.ts | 60 +- .../commands/connect/ui/welcome-content.tsx | 267 +++++ packages/novu/src/index.ts | 21 +- packages/shared/src/consts/providers/index.ts | 1 + .../consts/providers/managed-agent-spec.ts | 85 ++ 27 files changed, 3080 insertions(+), 1108 deletions(-) create mode 100644 packages/novu/src/commands/connect/dashboard-urls.ts create mode 100644 packages/novu/src/commands/connect/pipeline/resolve-agent-runtime-integration.ts create mode 100644 packages/novu/src/commands/connect/ui/agent-spec-labels.ts create mode 100644 packages/novu/src/commands/connect/ui/orb/orb-renderer.tsx create mode 100644 packages/novu/src/commands/connect/ui/orb/orb-tint.ts create mode 100644 packages/novu/src/commands/connect/ui/orb/use-preview-orb-morph.ts create mode 100644 packages/novu/src/commands/connect/ui/phase-content.tsx create mode 100644 packages/novu/src/commands/connect/ui/preview-field-config.ts create mode 100644 packages/novu/src/commands/connect/ui/preview-generated-content.tsx create mode 100644 packages/novu/src/commands/connect/ui/welcome-content.tsx create mode 100644 packages/shared/src/consts/providers/managed-agent-spec.ts diff --git a/apps/api/src/app/agents/agents.controller.ts b/apps/api/src/app/agents/agents.controller.ts index 93e6bd0d7b8..be33105cb6e 100644 --- a/apps/api/src/app/agents/agents.controller.ts +++ b/apps/api/src/app/agents/agents.controller.ts @@ -199,6 +199,7 @@ export class AgentsController { } @Post('/verify-credentials') + @ExternalApiAccessible() @ApiResponse(VerifyManagedCredentialsResponseDto) @ApiOperation({ summary: 'Verify managed-runtime credentials', diff --git a/apps/api/src/app/agents/e2e/verify-managed-credentials.e2e.ts b/apps/api/src/app/agents/e2e/verify-managed-credentials.e2e.ts index d3cd35c2f5f..1a42cbf5140 100644 --- a/apps/api/src/app/agents/e2e/verify-managed-credentials.e2e.ts +++ b/apps/api/src/app/agents/e2e/verify-managed-credentials.e2e.ts @@ -76,6 +76,19 @@ describe('Verify Managed Credentials API #novu-v2', () => { expect(mockProvider.validateCredentials.firstCall.args[0]).to.deep.equal({ apiKey: FAKE_API_KEY }); }); + it('accepts API key authentication for CLI connect flows', async () => { + const res = await session.testAgent + .post('/v1/agents/verify-credentials') + .set('authorization', `ApiKey ${session.apiKey}`) + .send({ + providerId: AgentRuntimeProviderIdEnum.Anthropic, + apiKey: FAKE_API_KEY, + }); + + expect(res.status).to.equal(201); + expect(res.body.data?.valid ?? res.body.valid).to.equal(true); + }); + it('returns 401 when the provider rejects the API key', async () => { mockProvider.validateCredentials.rejects( new AgentRuntimeUnauthorizedError('Invalid API key', AgentRuntimeProviderIdEnum.Anthropic) diff --git a/apps/api/src/app/agents/usecases/generate-managed-agent/managed-agent-generation.schema.ts b/apps/api/src/app/agents/usecases/generate-managed-agent/managed-agent-generation.schema.ts index 53437db9570..de52ffa754e 100644 --- a/apps/api/src/app/agents/usecases/generate-managed-agent/managed-agent-generation.schema.ts +++ b/apps/api/src/app/agents/usecases/generate-managed-agent/managed-agent-generation.schema.ts @@ -1,4 +1,13 @@ -import { CLAUDE_ANTHROPIC_SKILLS, CLAUDE_BUILTIN_TOOLS, MCP_SERVERS } from '@novu/shared'; +import { + CLAUDE_ANTHROPIC_SKILLS, + CLAUDE_BUILTIN_TOOLS, + MANAGED_AGENT_IDENTIFIER_MAX_LENGTH, + MANAGED_AGENT_NAME_MAX_LENGTH, + MANAGED_AGENT_SYSTEM_PROMPT_MAX_LENGTH, + MAX_GENERATED_MCP_SERVERS, + MAX_GENERATED_SKILLS, + MCP_SERVERS, +} from '@novu/shared'; import { z } from 'zod'; /** @@ -10,26 +19,24 @@ const TOOL_TYPES = CLAUDE_BUILTIN_TOOLS.map((tool) => tool.type) as [string, ... const MCP_SERVER_IDS = MCP_SERVERS.map((server) => server.id) as [string, ...string[]]; const SKILL_IDS = CLAUDE_ANTHROPIC_SKILLS.map((skill) => skill.skillId) as [string, ...string[]]; -/** Anthropic caps `mcp_servers` length; pick a conservative limit. */ -export const MAX_GENERATED_MCP_SERVERS = 5; -export const MAX_GENERATED_SKILLS = 4; +export { MAX_GENERATED_MCP_SERVERS, MAX_GENERATED_SKILLS }; export const managedAgentGenerationSchema = z.object({ name: z .string() .min(1) - .max(60) + .max(MANAGED_AGENT_NAME_MAX_LENGTH) .describe('Human readable agent name. Title case, 2–4 words (e.g., "PR Security Reviewer").'), identifier: z .string() .min(1) - .max(60) + .max(MANAGED_AGENT_IDENTIFIER_MAX_LENGTH) .regex(/^[a-z0-9-]+$/, 'Identifier must be lowercase kebab-case') .describe('Stable kebab-case identifier derived from the name (e.g., "pr-security-reviewer").'), systemPrompt: z .string() .min(1) - .max(4000) + .max(MANAGED_AGENT_SYSTEM_PROMPT_MAX_LENGTH) .describe( 'The full system prompt sent to Claude. Speak in second person to the agent ("You are…"). Describe role, scope, tone, and the workflow it should follow. Reference the available tools/MCPs/skills naturally without hard-coding them.' ), diff --git a/apps/dashboard/src/pages/cli-auth.tsx b/apps/dashboard/src/pages/cli-auth.tsx index 3801f7e1be5..b22599677b4 100644 --- a/apps/dashboard/src/pages/cli-auth.tsx +++ b/apps/dashboard/src/pages/cli-auth.tsx @@ -1,14 +1,14 @@ -import { useAuth as useClerkAuth } from '@clerk/react'; +import { useAuth as useClerkAuth, useClerk, useUser } from '@clerk/react'; import { FeatureFlagsKeysEnum, PermissionsEnum } from '@novu/shared'; import { AnimatePresence, motion } from 'motion/react'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { RiCheckLine, RiCommandLine, RiLockLine } from 'react-icons/ri'; +import { RiCheckLine, RiCommandLine, RiLockLine, RiArrowRightSLine } from 'react-icons/ri'; import { Navigate, useNavigate, useSearchParams } from 'react-router-dom'; import { approveCliDeviceSession } from '@/api/cli-auth'; import { AuthLayout } from '@/components/auth-layout'; +import { ConnectBrandLogo } from '@/components/auth/connect-brand-logo'; import { PageMeta } from '@/components/page-meta'; import { Button } from '@/components/primitives/button'; -import { Card, CardContent, CardHeader } from '@/components/primitives/card'; import { showErrorToast, showSuccessToast } from '@/components/primitives/sonner-helpers'; import { EnvironmentProvider } from '@/context/environment/environment-provider'; import { useEnvironment } from '@/context/environment/hooks'; @@ -17,6 +17,7 @@ import { useFetchApiKeys } from '@/hooks/use-fetch-api-keys'; import { useHasPermission } from '@/hooks/use-has-permission'; import { clearPendingCliAuth, storePendingCliAuth } from '@/utils/cli-auth-pending'; import { clearConnectProvisioning } from '@/utils/connect'; +import { buildAfterSignOutUrl } from '@/utils/cross-product-sign-out'; import { buildRoute, ROUTES } from '@/utils/routes'; function isValidDeviceCode(deviceCode: string | null): deviceCode is string { @@ -66,6 +67,8 @@ export const CliAuthPage = () => { function CliAuthContent() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); + const clerk = useClerk(); + const { user } = useUser(); const { currentEnvironment, environments, switchEnvironment } = useEnvironment(); const apiKeysQuery = useFetchApiKeys(); const has = useHasPermission(); @@ -78,14 +81,9 @@ function CliAuthContent() { const deviceCodeOk = isValidDeviceCode(deviceCode); const canReadApiKeys = has({ permission: PermissionsEnum.API_KEY_READ }); - // Two callers today: `novu-wizard` (default) and `novu-connect` (agent - // provisioning). Each gets its own subtitle + scope copy so the dashboard - // explains what the user is actually authorizing. const isConnect = callerName === 'novu-connect'; const callerDisplayName = isConnect ? 'Novu Connect' : 'Novu Wizard'; - const callerSubtitle = isConnect - ? 'to provision your AI agent and connect it to the channels you pick.' - : 'in order to integrate Novu into your project.'; + const signedInEmail = user?.primaryEmailAddress?.emailAddress; const apiKey = apiKeysQuery.data?.data?.[0]?.key; @@ -124,6 +122,18 @@ function CliAuthContent() { navigate(buildRoute(ROUTES.WORKFLOWS, { environmentSlug: currentEnvironment?.slug ?? 'default' })); } + const handleSignOut = useCallback(async () => { + const fallbackUrl = buildAfterSignOutUrl(); + + try { + await clerk.signOut({ redirectUrl: fallbackUrl }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Please try again.'; + showErrorToast(`Unable to sign out. ${message}`, 'Sign out failed'); + window.location.assign(fallbackUrl); + } + }, [clerk]); + const isLoading = apiKeysQuery.isLoading || !currentEnvironment; const reason = (() => { @@ -163,22 +173,22 @@ function CliAuthContent() { return (
- - -
- -

Authorize Novu CLI

+
+
+ + +
+

+ {callerDisplayName} would like to access your +
+ account and be able to: +

+
-

- {callerDisplayName} is requesting access to your{' '} - {currentEnvironment?.name ?? '...'} environment {callerSubtitle} -

- - - + {reason ? ( -
- +
+ {reason}
) : null} @@ -191,14 +201,14 @@ function CliAuthContent() { animate={{ opacity: 1, y: 0, height: 'auto' }} exit={{ opacity: 0, y: -4, height: 0 }} transition={{ duration: 0.2, ease: 'easeOut' }} - className="overflow-hidden" + className="w-full overflow-hidden" > -
+
@@ -212,48 +222,145 @@ function CliAuthContent() { animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -4, height: 0, marginTop: 0 }} transition={{ duration: 0.2, ease: 'easeOut' }} - className="flex items-center justify-end gap-2 overflow-hidden" + className="flex w-full max-w-[300px] flex-col items-center gap-3 overflow-hidden" > - - +
+ + +
)} - - + + {signedInEmail ? ( +

+ Signed in as + {signedInEmail} + · + +

+ ) : null} +
+
+
+ ); +} + +function CliAuthHeader({ + isConnect, + callerDisplayName, +}: { + isConnect: boolean; + callerDisplayName: string; +}) { + if (isConnect) { + return ; + } + + return ( +
+ + {callerDisplayName}
); } +type ScopePart = { + text: string; + bold?: boolean; +}; + +type ScopeItem = { + parts: ScopePart[]; +}; + function ScopeList({ isConnect }: { isConnect: boolean }) { - const scopes = isConnect + const scopes: ScopeItem[] = isConnect ? [ - 'Read your Novu API key for the selected environment', - 'Create and manage agents on your behalf', - 'Connect channels (Slack, Telegram, and more) to your agent', + { + parts: [{ text: 'Read', bold: true }, { text: ' your Novu API key for the selected environment' }], + }, + { + parts: [ + { text: 'Create', bold: true }, + { text: ' and ' }, + { text: 'manage', bold: true }, + { text: ' agents on your behalf' }, + ], + }, + { + parts: [{ text: 'Connect', bold: true }, { text: ' channels to your agent' }], + }, ] : [ - 'Read your Novu API key for the selected environment', - 'Trigger workflows on your behalf during the integration', - 'Create or update workflows via Novu MCP', + { + parts: [{ text: 'Read', bold: true }, { text: ' your Novu API key for the selected environment' }], + }, + { + parts: [{ text: 'Trigger', bold: true }, { text: ' workflows on your behalf during the integration' }], + }, + { + parts: [ + { text: 'Create', bold: true }, + { text: ' or ' }, + { text: 'update', bold: true }, + { text: ' workflows via Novu MCP' }, + ], + }, ]; return ( -
    +
      {scopes.map((scope) => ( -
    • - - {scope} +
    • part.text).join('')} className="flex min-h-6 items-center gap-2"> + +
    • ))}
    ); } + +function ScopeText({ parts }: { parts: ScopePart[] }) { + return ( + + {parts.map((part) => ( + + ))} + + ); +} + +function ScopeTextPart({ part }: { part: ScopePart }) { + if (part.bold) { + return {part.text}; + } + + return <>{part.text}; +} diff --git a/packages/novu/package.json b/packages/novu/package.json index fac2429697a..184bb481c8a 100644 --- a/packages/novu/package.json +++ b/packages/novu/package.json @@ -1,6 +1,6 @@ { "name": "novu", - "version": "2.8.1-rc.10", + "version": "2.8.1-rc.11", "description": "Novu CLI. Run Novu Studio and sync workflows with Novu Cloud", "main": "src/index.js", "publishConfig": { diff --git a/packages/novu/src/commands/connect/api/integrations.ts b/packages/novu/src/commands/connect/api/integrations.ts index 228be213ef9..415f13a070c 100644 --- a/packages/novu/src/commands/connect/api/integrations.ts +++ b/packages/novu/src/commands/connect/api/integrations.ts @@ -1,3 +1,4 @@ +import { AgentRuntimeProviderIdEnum } from '@novu/shared'; import type { ConnectApiClient } from './client'; export interface IntegrationRecord { @@ -17,6 +18,51 @@ export async function listIntegrations(client: ConnectApiClient): Promise { + await client.axios.post('/v1/agents/verify-credentials', { + providerId: input.providerId, + apiKey: input.apiKey, + ...(input.region ? { region: input.region } : {}), + ...(input.externalWorkspaceId ? { externalWorkspaceId: input.externalWorkspaceId } : {}), + }); +} + +export async function createAgentRuntimeIntegration( + client: ConnectApiClient, + input: { + environmentId: string; + providerId: AgentRuntimeProviderIdEnum; + name: string; + credentials: Record; + } +): Promise { + const res = await client.axios.post<{ data?: IntegrationRecord } | IntegrationRecord>('/v1/integrations', { + providerId: input.providerId, + kind: 'agent', + name: input.name, + active: true, + credentials: input.credentials, + _environmentId: input.environmentId, + }); + const body = res.data; + + return 'data' in body && body.data ? body.data : (body as IntegrationRecord); +} + +export async function deleteIntegration(client: ConnectApiClient, integrationId: string): Promise { + await client.axios.delete(`/v1/integrations/${encodeURIComponent(integrationId)}`); +} + export async function createSlackIntegration( client: ConnectApiClient, input: { name: string; environmentId: string } diff --git a/packages/novu/src/commands/connect/dashboard-urls.ts b/packages/novu/src/commands/connect/dashboard-urls.ts new file mode 100644 index 00000000000..f842af7b2cf --- /dev/null +++ b/packages/novu/src/commands/connect/dashboard-urls.ts @@ -0,0 +1,42 @@ +import type { ChannelChoice } from './types'; + +export const DASHBOARD_ONLY_CHANNELS: ReadonlyArray = ['whatsapp', 'teams']; + +export function buildConnectAgentDetailsUrl(input: { + connectDashboardUrl: string; + environmentSlug: string | null; + agentIdentifier: string; + tab?: 'integrations' | 'overview'; +}): string { + const base = input.connectDashboardUrl.replace(/\/$/, ''); + const agentPath = input.environmentSlug + ? `/env/${input.environmentSlug}/connect/agents/${encodeURIComponent(input.agentIdentifier)}` + : `/connect/agents/${encodeURIComponent(input.agentIdentifier)}`; + + if (input.tab === 'integrations') { + return `${base}${agentPath}/integrations`; + } + + return `${base}${agentPath}`; +} + +export function isDashboardOnlyChannel(channel: ChannelChoice): boolean { + return DASHBOARD_ONLY_CHANNELS.includes(channel); +} + +export function channelDisplayName(channel: ChannelChoice): string { + switch (channel) { + case 'whatsapp': + return 'WhatsApp'; + case 'teams': + return 'Microsoft Teams'; + case 'slack': + return 'Slack'; + case 'telegram': + return 'Telegram'; + case 'email': + return 'Email'; + default: + return channel; + } +} diff --git a/packages/novu/src/commands/connect/pipeline/channels/slack.ts b/packages/novu/src/commands/connect/pipeline/channels/slack.ts index 9d3aafe7e65..352feef3999 100644 --- a/packages/novu/src/commands/connect/pipeline/channels/slack.ts +++ b/packages/novu/src/commands/connect/pipeline/channels/slack.ts @@ -45,7 +45,7 @@ export async function connectSlackForAgent( return { connected: true, integration: slackIntegration }; } - const authorizeUrl = await getAuthorizeUrlWithQuickSetupFallback( + const { authorizeUrl, appCreated } = await getAuthorizeUrlWithQuickSetupFallback( client, agent, slackIntegration, @@ -53,12 +53,11 @@ export async function connectSlackForAgent( options, subscriberId ); - track(CONNECT_EVENTS.SLACK_OAUTH_OPENED, { agent: agent.identifier }); - ui.showSlackOAuthUrl(authorizeUrl); + await ui.awaitSlackOAuthOpen({ authorizeUrl, appCreated }); + track(CONNECT_EVENTS.SLACK_OAUTH_OPENED, { agent: agent.identifier, appCreated }); void open(authorizeUrl).catch(() => undefined); - - ui.pollingForSlackConnection(); + ui.showSlackWaiting({ authorizeUrl }); const connected = await pollUntil( async () => { const count = await countChannelConnectionsForIntegration(client, slackIntegration.identifier); @@ -87,7 +86,7 @@ async function getAuthorizeUrlWithQuickSetupFallback( ui: ConnectUI, options: ConnectCommandOptions, subscriberId: string -): Promise { +): Promise<{ authorizeUrl: string; appCreated: boolean }> { const buildUrl = () => generateConnectOauthUrl(client, { integrationIdentifier: slackIntegration.identifier, @@ -96,20 +95,26 @@ async function getAuthorizeUrlWithQuickSetupFallback( }); try { - return await buildUrl(); + const authorizeUrl = await buildUrl(); + + return { authorizeUrl, appCreated: false }; } catch (err) { if (!isMissingSlackCredentialsError(err)) throw err; await runSlackQuickSetup(client, agent, slackIntegration, ui, options, { retry: false }); try { - return await buildUrl(); + const authorizeUrl = await buildUrl(); + + return { authorizeUrl, appCreated: true }; } catch (retryErr) { if (!isMissingSlackCredentialsError(retryErr)) throw retryErr; await runSlackQuickSetup(client, agent, slackIntegration, ui, options, { retry: true }); - return await buildUrl(); + const authorizeUrl = await buildUrl(); + + return { authorizeUrl, appCreated: true }; } } } diff --git a/packages/novu/src/commands/connect/pipeline/resolve-agent-runtime-integration.ts b/packages/novu/src/commands/connect/pipeline/resolve-agent-runtime-integration.ts new file mode 100644 index 00000000000..32f319ae4c0 --- /dev/null +++ b/packages/novu/src/commands/connect/pipeline/resolve-agent-runtime-integration.ts @@ -0,0 +1,227 @@ +import { + AgentRuntimeProviderIdEnum, + buildManagedIntegrationCredentials, + hasCompleteManagedCredentials, + IntegrationKindEnum, + type ManagedCredentialFields, +} from '@novu/shared'; +import { + createAgentRuntimeIntegration, + type IntegrationRecord, + listIntegrations, + verifyManagedCredentials, +} from '../api/integrations'; +import type { ConnectApiClient } from '../api/client'; +import type { AgentRuntimeChoice, ConnectCommandOptions } from '../types'; +import type { ConnectUI } from '../ui/ui'; + +const AGENT_INTEGRATION_KIND = IntegrationKindEnum.AGENT; + +export type ResolvedRuntimeIntegration = { + integrationId: string; + providerId: AgentRuntimeProviderIdEnum; + createdInThisFlow: boolean; +}; + +export function resolveRuntimeProviderId(runtime: AgentRuntimeChoice): AgentRuntimeProviderIdEnum { + switch (runtime) { + case 'demo': + return AgentRuntimeProviderIdEnum.NovuAnthropic; + case 'claude': + return AgentRuntimeProviderIdEnum.Anthropic; + case 'claude-aws': + return AgentRuntimeProviderIdEnum.AnthropicAws; + } +} + +export function resolveRuntimeFromOptions(options: ConnectCommandOptions): AgentRuntimeChoice | undefined { + return options.runtime; +} + +export async function resolveAgentRuntimeIntegration( + client: ConnectApiClient, + ui: ConnectUI, + options: ConnectCommandOptions, + runtime: AgentRuntimeChoice, + environmentId: string +): Promise { + const integrations = await listIntegrations(client); + + if (runtime === 'demo') { + return resolveDemoIntegration(integrations); + } + + const providerId = resolveRuntimeProviderId(runtime); + + if (options.agentIntegrationId) { + return resolveExplicitIntegration(integrations, options.agentIntegrationId, providerId); + } + + const existing = listAgentIntegrationsForProvider(integrations, providerId); + + if (existing.length > 0 && !hasByokCredentialFlags(options, runtime)) { + const pick = await ui.pickAgentIntegration({ + providerLabel: runtime === 'claude-aws' ? 'AWS Claude' : 'Anthropic', + integrations: existing, + }); + + if (pick.kind === 'existing') { + const integration = existing.find((item) => item._id === pick.integrationId); + if (!integration) { + throw new Error('Selected integration was not found. Re-run `npx novu connect`.'); + } + + return { + integrationId: integration._id, + providerId: integration.providerId as AgentRuntimeProviderIdEnum, + createdInThisFlow: false, + }; + } + } + + const fields = await resolveManagedCredentialFields(ui, options, runtime); + if (!hasCompleteManagedCredentials(providerId, fields)) { + throw new Error('Complete credentials are required for the selected agent runtime.'); + } + + ui.verifyingCredentials(); + await verifyManagedCredentials(client, { + providerId, + apiKey: fields.apiKey.trim(), + region: fields.region?.trim(), + externalWorkspaceId: fields.externalWorkspaceId?.trim(), + }); + ui.credentialsVerified(); + + const integrationName = + runtime === 'claude-aws' ? 'Novu Connect AWS Claude' : 'Novu Connect Anthropic'; + + const created = await createAgentRuntimeIntegration(client, { + environmentId, + providerId, + name: integrationName, + credentials: buildManagedIntegrationCredentials(providerId, fields), + }); + + return { + integrationId: created._id, + providerId, + createdInThisFlow: true, + }; +} + +function resolveDemoIntegration(integrations: IntegrationRecord[]): ResolvedRuntimeIntegration { + const demo = integrations.find( + (integration) => + integration.providerId === AgentRuntimeProviderIdEnum.NovuAnthropic && + integration.kind === AGENT_INTEGRATION_KIND && + integration.active !== false + ); + + if (!demo) { + throw new Error( + "This environment doesn't have a Novu demo Claude integration. " + + 'Choose Anthropic or AWS Claude with your own credentials, or set up the demo integration in the dashboard.' + ); + } + + return { + integrationId: demo._id, + providerId: AgentRuntimeProviderIdEnum.NovuAnthropic, + createdInThisFlow: false, + }; +} + +function resolveExplicitIntegration( + integrations: IntegrationRecord[], + integrationId: string, + expectedProviderId: AgentRuntimeProviderIdEnum +): ResolvedRuntimeIntegration { + const integration = integrations.find((item) => item._id === integrationId); + + if (!integration || integration.kind !== AGENT_INTEGRATION_KIND) { + throw new Error(`Integration "${integrationId}" was not found or is not an agent integration.`); + } + + if (integration.providerId !== expectedProviderId) { + throw new Error( + `Integration "${integrationId}" uses provider "${integration.providerId}", expected "${expectedProviderId}".` + ); + } + + return { + integrationId: integration._id, + providerId: integration.providerId as AgentRuntimeProviderIdEnum, + createdInThisFlow: false, + }; +} + +function listAgentIntegrationsForProvider( + integrations: IntegrationRecord[], + providerId: AgentRuntimeProviderIdEnum +): IntegrationRecord[] { + return integrations.filter( + (integration) => + integration.providerId === providerId && + integration.kind === AGENT_INTEGRATION_KIND && + integration.active !== false + ); +} + +function hasByokCredentialFlags(options: ConnectCommandOptions, runtime: AgentRuntimeChoice): boolean { + if (runtime === 'claude') { + return Boolean(options.anthropicApiKey?.trim()); + } + + if (runtime === 'claude-aws') { + return Boolean( + options.awsClaudeApiKey?.trim() && + options.awsClaudeRegion?.trim() && + options.awsClaudeWorkspaceId?.trim() + ); + } + + return false; +} + +async function resolveManagedCredentialFields( + ui: ConnectUI, + options: ConnectCommandOptions, + runtime: AgentRuntimeChoice +): Promise { + if (runtime === 'claude') { + const apiKey = + options.anthropicApiKey?.trim() ?? + (await ui.promptForSecretInput({ + title: 'Anthropic API key', + placeholder: 'sk-ant-…', + hint: 'Used to create a Claude managed agent integration in your Novu environment.', + })); + + return { apiKey: apiKey.trim() }; + } + + const apiKey = + options.awsClaudeApiKey?.trim() ?? + (await ui.promptForSecretInput({ + title: 'AWS Claude API key', + placeholder: 'sk-ant-…', + hint: 'API key for your AWS Claude Platform workspace.', + })); + const region = + options.awsClaudeRegion?.trim() ?? (await ui.pickAwsClaudeRegion()); + const externalWorkspaceId = + options.awsClaudeWorkspaceId?.trim() ?? + (await ui.promptForSecretInput({ + title: 'AWS Claude workspace ID', + placeholder: 'wrkspc_…', + hint: 'Workspace ID from the AWS Claude Platform console.', + secret: false, + })); + + return { + apiKey: apiKey.trim(), + region: region.trim(), + externalWorkspaceId: externalWorkspaceId.trim(), + }; +} diff --git a/packages/novu/src/commands/connect/pipeline/runner.ts b/packages/novu/src/commands/connect/pipeline/runner.ts index 73cde82ba15..36a31c38386 100644 --- a/packages/novu/src/commands/connect/pipeline/runner.ts +++ b/packages/novu/src/commands/connect/pipeline/runner.ts @@ -1,3 +1,4 @@ +import open from 'open'; import { resolveAuth } from '../../wizard/auth/resolve-auth'; import type { ResolvedAuth, WizardCommandOptions } from '../../wizard/types'; import { CONNECT_EVENTS } from '../analytics/events'; @@ -9,16 +10,18 @@ import { sendAgentWelcomeMessage, } from '../api/agents'; import { type ConnectApiClient, createConnectApiClient, NovuApiError } from '../api/client'; -import { type IntegrationRecord, listIntegrations } from '../api/integrations'; +import { type IntegrationRecord, deleteIntegration } from '../api/integrations'; import { upsertSubscriber } from '../api/subscribers'; +import { buildConnectAgentDetailsUrl, channelDisplayName } from '../dashboard-urls'; import type { AgentSummary, ChannelChoice, ConnectCommandOptions } from '../types'; import type { ConnectUI } from '../ui/ui'; import { connectEmailForAgent } from './channels/email'; import { connectSlackForAgent } from './channels/slack'; import { connectTelegramForAgent } from './channels/telegram'; - -const NOVU_ANTHROPIC_PROVIDER_ID = 'novu-anthropic'; -const AGENT_INTEGRATION_KIND = 'agent'; +import { + resolveAgentRuntimeIntegration, + resolveRuntimeFromOptions, +} from './resolve-agent-runtime-integration'; export interface ConnectPipelineInput { options: ConnectCommandOptions; @@ -63,12 +66,12 @@ export async function runConnectPipeline(input: ConnectPipelineInput): Promise undefined); + dashboardRedirectChannel = channel; break; + } + default: + throw new Error(`${channelDisplayName(channel)} is not supported in the connect CLI yet.`); } if (channelConnected && connectedIntegration) { @@ -141,8 +158,10 @@ export async function runConnectPipeline(input: ConnectPipelineInput): Promise { + const runtime = + resolveRuntimeFromOptions(options) ?? + (await ui.pickAgentRuntime({ preselected: options.runtime ?? 'demo' })); + ui.loadingIntegrations(); - const integrations = await listIntegrations(client); - const novuAnthropic = integrations.find( - (i) => i.providerId === NOVU_ANTHROPIC_PROVIDER_ID && i.kind === AGENT_INTEGRATION_KIND && i.active !== false - ); - - if (!novuAnthropic) { - throw new Error( - "This environment doesn't have a Novu-managed Claude integration. " + - 'Set one up in the dashboard, then re-run `npx novu connect`.' - ); - } + const resolved = await resolveAgentRuntimeIntegration(client, ui, options, runtime, environmentId); const prompt = await ui.promptForDescription(options.prompt); - if (prompt.trim().length < 8) { - throw new Error('Agent description must be at least 8 characters.'); + let generated = await generateAndPreviewAgent(client, ui, prompt.trim()); + + ui.creatingAgent(generated.name); + + try { + const created = await createManagedAgent(client, { + name: generated.name, + identifier: generated.identifier, + integrationId: resolved.integrationId, + providerId: resolved.providerId, + systemPrompt: generated.systemPrompt, + tools: generated.tools, + mcpServers: generated.mcpServers, + skills: generated.skills, + }); + + return toSummary(created); + } catch (err) { + if (resolved.createdInThisFlow) { + try { + await deleteIntegration(client, resolved.integrationId); + } catch { + // Best-effort cleanup. + } + } + + throw err; } +} + +async function generateAndPreviewAgent( + client: ConnectApiClient, + ui: ConnectUI, + initialPrompt: string +): Promise>> { + let prompt = initialPrompt; + + while (true) { + if (prompt.trim().length < 8) { + throw new Error('Agent description must be at least 8 characters.'); + } - ui.generatingAgent(); - const generated = await generateAgent(client, prompt.trim()); + ui.generatingAgent(); + const generated = await generateAgent(client, prompt.trim()); + const result = await ui.previewGeneratedAgent(generated); - ui.creatingAgent(generated.name); - const created = await createManagedAgent(client, { - name: generated.name, - identifier: generated.identifier, - integrationId: novuAnthropic._id, - providerId: NOVU_ANTHROPIC_PROVIDER_ID, - systemPrompt: generated.systemPrompt, - tools: generated.tools, - mcpServers: generated.mcpServers, - skills: generated.skills, - }); - - return toSummary(created); + if (result.action === 'confirm') { + return result.spec; + } + + prompt = await ui.refineDescription(prompt.trim()); + } } async function ensureSubscriberForUser(client: ConnectApiClient, auth: ResolvedAuth): Promise { diff --git a/packages/novu/src/commands/connect/types.ts b/packages/novu/src/commands/connect/types.ts index adaddb1cab2..e6e7e1b486e 100644 --- a/packages/novu/src/commands/connect/types.ts +++ b/packages/novu/src/commands/connect/types.ts @@ -4,6 +4,10 @@ export type ChannelChoice = 'slack' | 'email' | 'whatsapp' | 'telegram' | 'teams export const CHANNEL_CHOICES: readonly ChannelChoice[] = ['slack', 'email', 'whatsapp', 'telegram', 'teams', 'skip']; +export type AgentRuntimeChoice = 'demo' | 'claude' | 'claude-aws'; + +export const AGENT_RUNTIME_CHOICES: readonly AgentRuntimeChoice[] = ['demo', 'claude', 'claude-aws']; + export interface ConnectCommandOptions { secretKey?: string; region: CloudRegionEnum; @@ -13,6 +17,21 @@ export interface ConnectCommandOptions { connectDashboardUrl: string; /** Pre-fill the agent description, skipping the input screen. Enables non-interactive runs. */ prompt?: string; + /** + * Agent runtime for new agents. `demo` uses Novu's demo Claude integration (default). + * `claude` and `claude-aws` require your own credentials unless an integration already exists. + */ + runtime?: AgentRuntimeChoice; + /** Use an existing agent-runtime integration instead of creating one. */ + agentIntegrationId?: string; + /** Anthropic API key for `--runtime claude` non-interactive runs. */ + anthropicApiKey?: string; + /** AWS Claude API key for `--runtime claude-aws` non-interactive runs. */ + awsClaudeApiKey?: string; + /** AWS Claude region for `--runtime claude-aws` non-interactive runs. */ + awsClaudeRegion?: string; + /** AWS Claude workspace ID for `--runtime claude-aws` non-interactive runs. */ + awsClaudeWorkspaceId?: string; /** Pre-select the channel to connect, skipping the picker. Currently only `slack` is implemented. */ channel?: ChannelChoice; /** diff --git a/packages/novu/src/commands/connect/ui/agent-spec-labels.ts b/packages/novu/src/commands/connect/ui/agent-spec-labels.ts new file mode 100644 index 00000000000..4909fd9ba4f --- /dev/null +++ b/packages/novu/src/commands/connect/ui/agent-spec-labels.ts @@ -0,0 +1,103 @@ +import { + CLAUDE_ANTHROPIC_SKILLS, + CLAUDE_BUILTIN_TOOLS, + MANAGED_AGENT_IDENTIFIER_MAX_LENGTH, + MCP_SERVERS, + slugify, +} from '@novu/shared'; +import type { GeneratedAgentSpec } from '../api/agents'; + +const TOOL_LABELS = new Map(CLAUDE_BUILTIN_TOOLS.map((tool) => [tool.type, tool.name])); +const MCP_LABELS = new Map(MCP_SERVERS.map((server) => [server.id, server.name])); +const SKILL_LABELS = new Map(CLAUDE_ANTHROPIC_SKILLS.map((skill) => [skill.skillId, skill.name])); + +export type CatalogSelectOption = { + label: string; + value: string; +}; + +export type GeneratedAgentSpecLabels = { + tools: string[]; + mcpServers: string[]; + skills: string[]; +}; + +export function resolveGeneratedAgentSpecLabels(spec: GeneratedAgentSpec): GeneratedAgentSpecLabels { + return { + tools: spec.tools.map((id) => TOOL_LABELS.get(id) ?? id), + mcpServers: spec.mcpServers.map((id) => MCP_LABELS.get(id) ?? id), + skills: spec.skills.map((skill) => SKILL_LABELS.get(skill.skillId) ?? skill.skillId), + }; +} + +export function buildToolSelectOptions(): CatalogSelectOption[] { + return CLAUDE_BUILTIN_TOOLS.map((tool) => ({ + label: tool.name, + value: tool.type, + })); +} + +export function buildMcpSelectOptions(): CatalogSelectOption[] { + return MCP_SERVERS.filter((server) => server.oauth).map((server) => ({ + label: server.name, + value: server.id, + })); +} + +export function buildSkillSelectOptions(): CatalogSelectOption[] { + return CLAUDE_ANTHROPIC_SKILLS.map((skill) => ({ + label: skill.name, + value: skill.skillId, + })); +} + +export function slugifyAgentIdentifier(name: string): string { + const slug = slugify(name.trim()); + + return slug.slice(0, MANAGED_AGENT_IDENTIFIER_MAX_LENGTH) || 'agent'; +} + +export function wrapPreviewLines(text: string, maxWidth: number, maxLines: number): { lines: string[]; truncated: boolean } { + const normalized = text.replace(/\s+/g, ' ').trim(); + if (!normalized) { + return { lines: ['—'], truncated: false }; + } + + const words = normalized.split(' '); + const lines: string[] = []; + let current = ''; + + for (const word of words) { + const candidate = current ? `${current} ${word}` : word; + if (candidate.length <= maxWidth) { + current = candidate; + continue; + } + + if (current) { + lines.push(current); + current = word; + } else { + lines.push(word.slice(0, maxWidth)); + current = word.slice(maxWidth); + } + + if (lines.length >= maxLines) { + return { lines: lines.slice(0, maxLines), truncated: true }; + } + } + + if (lines.length < maxLines && current) { + lines.push(current); + } + + return { lines: lines.slice(0, maxLines), truncated: false }; +} + +export function formatCapabilitySummary(labels: string[]): string { + if (labels.length === 0) { + return 'None selected'; + } + + return labels.join(', '); +} diff --git a/packages/novu/src/commands/connect/ui/app.tsx b/packages/novu/src/commands/connect/ui/app.tsx index 5280b4bd4eb..34408214436 100644 --- a/packages/novu/src/commands/connect/ui/app.tsx +++ b/packages/novu/src/commands/connect/ui/app.tsx @@ -1,67 +1,31 @@ -import { Select, TextInput } from '@inkjs/ui'; -import { Box, Text, useApp, useInput } from 'ink'; +import { Box, useApp, useInput } from 'ink'; // biome-ignore lint/correctness/noUnusedImports: classic-JSX linter falls back here because tsconfig.json excludes ui/. import React from 'react'; import type { ChannelChoice } from '../types'; +import { computeOrbLabel, computeOrbTint } from './orb/orb-tint'; +import { PersistentOrb } from './orb/orb-renderer'; +import { usePreviewOrbMorph } from './orb/use-preview-orb-morph'; +import { PhaseContent } from './phase-content'; import type { ConnectStore } from './store'; import { useStore } from './use-store'; -/** - * Channel brand colours used to tint the orb when a channel is hovered or - * active. These are well-known brand-colour hexes; we deliberately do NOT - * render any brand logos — just colour + a single letter glyph as an - * integration identifier. - */ -const CHANNEL_TINTS: Record = { - slack: '#ECB22E', // Slack yellow - telegram: '#26A5E4', // Telegram blue - email: '#34A853', // generic mail green - whatsapp: '#25D366', // WhatsApp green - teams: '#5059C9', // Teams indigo - skip: 'white', -}; -const DEFAULT_ORB_COLOR = 'white'; - -/** - * Plain text channel names rendered inside the orb. Plain words, not logos. - * `skip` is undefined so the orb stays plain when the user opts out. - */ -const CHANNEL_LABELS: Partial> = { - slack: 'SLACK', - telegram: 'TELEGRAM', - email: 'EMAIL', - whatsapp: 'WHATSAPP', - teams: 'TEAMS', -}; - export interface AppProps { store: ConnectStore; /** Called by the app once it has mounted, so the controller can wire the Ink exit. */ registerExit: (exit: () => void) => void; } -const NEW_AGENT_VALUE = '__new__'; - export function App({ store, registerExit }: AppProps): React.ReactElement { const phase = useStore(store.phase); const { exit } = useApp(); - // Tracks which channel the user is hovering in the picker, so the orb can - // tint to that brand colour before they commit. Reset to `null` when we - // leave the picker — the channel-specific phases below derive their tint - // directly from the phase kind. const [hoveredChannel, setHoveredChannel] = React.useState(null); + const { previewMorphProgress, previewMorphComplete } = usePreviewOrbMorph(phase.kind); React.useEffect(() => { registerExit(exit); }, [exit, registerExit]); - // Global Ctrl+C handler. We render Ink with `exitOnCtrlC: false` so child - // input handlers (Select, TextInput, etc.) get a clean shot at keystrokes - // without Ink unmounting under them. The side-effect is Ctrl+C goes - // nowhere unless we wire it ourselves — this top-level handler runs - // regardless of which phase / focused widget is active. Exit code 130 - // matches the conventional SIGINT exit. useInput((input, key) => { if (key.ctrl && input === 'c') { process.exitCode = 130; @@ -73,941 +37,21 @@ export function App({ store, registerExit }: AppProps): React.ReactElement { if (phase.kind !== 'pick-channel') setHoveredChannel(null); }, [phase.kind]); - const tintColor = computeOrbTint(phase, hoveredChannel); + const tintColor = computeOrbTint(phase, hoveredChannel, previewMorphProgress); const label = computeOrbLabel(phase, hoveredChannel); - // Layout pattern: the orb lives at the top of every screen, always - // breathing/shimmering. Everything else slots beneath it, horizontally - // centered so the welcome text / phase content / QR codes all line up - // visually with the orb's center. Single persistent visual identity - // instead of a different header/spinner per phase. return ( - - + + ); } - -/** - * Derive the orb's colour from the current phase plus, for the picker only, - * the channel currently being hovered. Falls back to white whenever there's - * no channel context (auth, generating, etc.) so the orb stays neutral - * outside of channel selection. - */ -function computeOrbTint( - phase: ReturnType, - hoveredChannel: ChannelChoice | null -): string { - switch (phase.kind) { - case 'pick-channel': - return hoveredChannel ? CHANNEL_TINTS[hoveredChannel] : DEFAULT_ORB_COLOR; - case 'adding-slack': - case 'paste-slack-token': - case 'running-slack-quick-setup': - case 'waiting-slack': - return CHANNEL_TINTS.slack; - case 'adding-telegram': - case 'telegram-intro': - case 'telegram-link-token': - case 'telegram-test': - return CHANNEL_TINTS.telegram; - case 'adding-email': - case 'email-ready': - return CHANNEL_TINTS.email; - case 'success': - return phase.connectedChannel ? CHANNEL_TINTS[phase.connectedChannel] : DEFAULT_ORB_COLOR; - default: - return DEFAULT_ORB_COLOR; - } -} - -/** - * Pick the channel label (SLACK / TELEGRAM / EMAIL / WHATSAPP / TEAMS) - * rendered inside the orb for the current phase. Returns undefined when - * there's no channel context — the orb stays plain on auth/generating/etc. - */ -function computeOrbLabel( - phase: ReturnType, - hoveredChannel: ChannelChoice | null -): string | undefined { - switch (phase.kind) { - case 'pick-channel': - return hoveredChannel ? CHANNEL_LABELS[hoveredChannel] : undefined; - case 'adding-slack': - case 'paste-slack-token': - case 'running-slack-quick-setup': - case 'waiting-slack': - return CHANNEL_LABELS.slack; - case 'adding-telegram': - case 'telegram-intro': - case 'telegram-link-token': - case 'telegram-test': - return CHANNEL_LABELS.telegram; - case 'adding-email': - case 'email-ready': - return CHANNEL_LABELS.email; - case 'success': - return phase.connectedChannel ? CHANNEL_LABELS[phase.connectedChannel] : undefined; - default: - return undefined; - } -} - -function PhaseContent({ - phase, - onChannelHover, -}: { - phase: ReturnType; - onChannelHover: (channel: ChannelChoice | null) => void; -}): React.ReactElement { - switch (phase.kind) { - case 'welcome': - return ; - - case 'auth': - return ( - - {phase.status} - {phase.dashboardUrl ? ( - - If your browser didn't open, visit: - {phase.dashboardUrl} - - ) : null} - - ); - - case 'listing-agents': - return Checking for existing agents…; - - case 'loading-integrations': - return Looking up managed integrations…; - - case 'pick': { - const options = [ - ...phase.agents.map((agent) => ({ - label: `${agent.name} (${agent.identifier})`, - value: agent.id, - })), - { label: '+ Create a new agent', value: NEW_AGENT_VALUE }, - ]; - - return ( - - You already have agents in this environment. What would you like to do? - { + if (value === NEW_INTEGRATION_VALUE) { + phase.resolve({ kind: 'new' }); + + return; + } + + phase.resolve({ kind: 'existing', integrationId: value }); + }} + /> + + ); + } + + case 'prompt-secret': + return ( + + {phase.title} + {phase.hint ? {phase.hint} : null} + + phase.resolve(value)} /> + + Press Enter to submit. + + ); + + case 'pick-aws-region': { + const options = AWS_CLAUDE_COMMERCIAL_REGIONS.map((region) => ({ + label: region, + value: region, + })); + + return ( + + AWS Claude region + Select the commercial region for your AWS Claude Platform workspace. + { + if (value === NEW_AGENT_VALUE) { + phase.resolve({ action: 'new' }); + + return; + } + const agent = phase.agents.find((a) => a.id === value); + if (agent) phase.resolve({ action: 'use', agent }); + }} + /> + + ); + } + + case 'describe': + return ( + + {phase.previousPrompt ? 'Refine your description' : 'Describe your agent'} + {phase.previousPrompt ? ( + {`Previous: "${truncateInline(phase.previousPrompt, 72)}"`} + ) : null} + e.g. a customer-support agent that books demos and escalates billing questions. + + phase.resolve(value)} + /> + + Press Enter to submit. Minimum 8 characters. + + ); + + case 'generating': + return ; + + case 'preview-generated': + return ( + + ); + + case 'creating': + return {`Creating agent "${phase.name}"…`}; + + case 'pick-channel': { + const options: Array<{ label: string; value: ChannelChoice }> = [ + { label: 'Slack (recommended)', value: 'slack' }, + { label: 'Telegram', value: 'telegram' }, + { label: 'Email', value: 'email' }, + { label: 'WhatsApp', value: 'whatsapp' }, + { label: 'Microsoft Teams', value: 'teams' }, + { label: 'Skip — set up later in dashboard', value: 'skip' }, + ]; + + return ( + + + Pick a channel to connect this agent to + + phase.resolve(value)} + onHighlight={onChannelHover} + /> + + ); + } + + case 'adding-slack': + return Linking Slack to your agent…; + + case 'paste-slack-token': + return ( + + Paste a Slack App Configuration Token + + Your Slack integration has no OAuth credentials yet. Novu can create the Slack app for you from a manifest + if you paste a short-lived configuration token. + + + 1. Open + https://api.slack.com/apps + 2. Scroll to the bottom of the page + 3. Generate an App Configuration Token + 4. Copy the access token (starts with xoxe.xoxp-) + + {phase.retry ? ( + Previous token was rejected by Slack. Generate a fresh one and try again. + ) : null} + + { + const trimmed = value.trim(); + if (!trimmed) { + phase.reject(new Error('No Slack App Configuration Token provided.')); + + return; + } + phase.resolve(trimmed); + }} + /> + + The token is sent to your Novu API once, used to create the Slack app, then discarded. + + ); + + case 'running-slack-quick-setup': + return Creating Slack app from manifest…; + + case 'slack-oauth-ready': + return ( + + ); + + case 'waiting-slack': + return ( + + Authorize Slack to finish setup + + Opened in your browser. If nothing happened, visit: + {phase.authorizeUrl} + + Waiting for Slack authorization… + + ); + + case 'adding-email': + return Linking Email to your agent…; + + case 'email-ready': + return ( + + ); + + case 'email-waiting': + return ( + + + Send any message to your agent + + + {phase.inboundAddress} + + Waiting for your email to arrive… + + ); + + case 'adding-telegram': + return Linking Telegram to your agent…; + + case 'telegram-intro': + return ; + + case 'telegram-link-token': + return ( + + + Step 2 of 3 · Save your bot token + + + Scan with your phone to open a page where you can paste the BotFather token. We'll handle registering the + webhook for you. + + {phase.mobileQr} + + Or open this on your phone: + {phase.mobileUrl} + + Waiting for your bot token… + + ); + + case 'telegram-test': + return ( + + + Step 3 of 3 · Say hello to your bot + + + Scan to open @{phase.botUsername} in Telegram and tap Start. + + {phase.deepLinkQr} + + Or open this link: + {phase.deepLinkUrl} + + Waiting for /start in Telegram… + + ); + + case 'sending-welcome': + return Asking your agent to say hello…; + + case 'dashboard-channel-ready': + return ( + + ); + + case 'success': + return ; + + case 'error': + return ✗ {phase.message}; + + default: + return ; + } +} + +function RuntimeSelect({ + onChange, +}: { + onChange: (value: AgentRuntimeChoice) => void; +}): React.ReactElement { + const options: Array<{ value: AgentRuntimeChoice; title: string; detail?: string }> = [ + { value: 'demo', title: 'Novu Demo Agent', detail: '10 conversations per month' }, + { value: 'claude', title: 'Claude Managed Agents - BYOK' }, + { value: 'claude-aws', title: 'Claude Managed Agents on AWS' }, + ]; + const [idx, setIdx] = React.useState(0); + + useInput((_input, key) => { + if (key.upArrow) { + setIdx((current) => (current - 1 + options.length) % options.length); + } else if (key.downArrow) { + setIdx((current) => (current + 1) % options.length); + } else if (key.return) { + onChange(options[idx].value); + } + }); + + return ( + + {options.map((opt, i) => { + const isSelected = i === idx; + + return ( + + + {isSelected ? '› ' : ' '} + {opt.title} + + {opt.detail ? {` · ${opt.detail}`} : null} + + ); + })} + + ); +} + +const DASHBOARD_CHANNEL_HINT = + 'Onboarding for this channel is currently only available in the Novu Connect UI.'; +/** Keeps the picker + hint from widening the centered layout when the hint appears. */ +const CHANNEL_PICKER_WIDTH = 48; + +function ChannelSelect({ + options, + onChange, + onHighlight, +}: { + options: Array<{ label: string; value: ChannelChoice }>; + onChange: (value: ChannelChoice) => void; + onHighlight: (value: ChannelChoice | null) => void; +}): React.ReactElement { + const [idx, setIdx] = React.useState(0); + + // Seed the parent with the initial highlight so the orb doesn't sit on + // white for a frame before the user touches the arrow keys. + React.useEffect(() => { + onHighlight(options[0]?.value ?? null); + // We only want to fire on mount; subsequent highlights flow through useInput. + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional mount-only effect + }, []); + + useInput((_input, key) => { + if (key.upArrow) { + const next = (idx - 1 + options.length) % options.length; + setIdx(next); + onHighlight(options[next].value); + } else if (key.downArrow) { + const next = (idx + 1) % options.length; + setIdx(next); + onHighlight(options[next].value); + } else if (key.return) { + onChange(options[idx].value); + } + }); + + const highlighted = options[idx]?.value ?? null; + const showDashboardHint = highlighted !== null && isDashboardOnlyChannel(highlighted); + + return ( + + + {options.map((opt, i) => { + const isSelected = i === idx; + const opensInDashboard = isDashboardOnlyChannel(opt.value); + const prefix = isSelected ? '› ' : ' '; + + return ( + + {prefix} + {opt.label} + {opensInDashboard ? : null} + + ); + })} + + + {showDashboardHint ? ( + + {DASHBOARD_CHANNEL_HINT} + + ) : ( + <> + + + + )} + + + ); +} +function GeneratingContent(): React.ReactElement { + const [elapsed, setElapsed] = React.useState(0); + + React.useEffect(() => { + const startedAt = Date.now(); + const t = setInterval(() => setElapsed(Math.floor((Date.now() - startedAt) / 1000)), 1000); + + return () => clearInterval(t); + }, []); + + // Hold each tagline for ~3s before rotating. The orb keeps moving; this + // gives the user words for what's happening without re-rendering a spinner + // line right above the orb. + const tagline = TAGLINES[Math.floor(elapsed / 3) % TAGLINES.length]; + + return ( + + + + Crafting your agent + + · {elapsed}s + + {tagline} + + ); +} + +function truncateInline(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + + return `${text.slice(0, maxLength - 1)}…`; +} + +function SlackOAuthReadyContent({ + appCreated, + authorizeUrl, + onContinue, +}: { + appCreated: boolean; + authorizeUrl: string; + onContinue: () => void; +}): React.ReactElement { + useInput((_input, key) => { + if (key.return || _input === ' ') onContinue(); + }); + + return ( + + {appCreated ? ( + <> + + Slack app created successfully + + + Novu created a Slack app for your agent. Next, add it to your workspace so your team can talk to the agent + in Slack. + + + ) : ( + <> + + Connect Slack to your agent + + Authorize Novu to install the Slack app in your workspace. + + )} + {`OAuth link: ${authorizeUrl.slice(0, 80)}${authorizeUrl.length > 80 ? '…' : ''}`} + Press Enter to open Slack and add the app to your workspace → + + ); +} + +function EmailReadyContent({ + inboundAddress, + mailtoUrl, + onContinue, +}: { + inboundAddress: string; + mailtoUrl: string; + onContinue: () => void; +}): React.ReactElement { + useInput((_input, key) => { + if (key.return || _input === ' ') onContinue(); + }); + + return ( + + + Your agent has an inbox + + Send any email to the address below — your agent will read it and reply to your inbox. + + {inboundAddress} + + {`mailto link: ${mailtoUrl.slice(0, 80)}${mailtoUrl.length > 80 ? '…' : ''}`} + Press Enter to open a pre-filled draft in your default mail client → + + ); +} + +function TelegramIntroContent({ + botfatherQr, + onContinue, +}: { + botfatherQr: string; + onContinue: () => void; +}): React.ReactElement { + useInput((_input, key) => { + if (key.return || _input === ' ') { + onContinue(); + } + }); + + return ( + + + Step 1 of 3 · Create your Telegram bot + + + + + 1. + {' '} + Open Telegram and message @BotFather. + + + + 2. + {' '} + Run /newbot, choose a name and username. + + + + 3. + {' '} + Keep the BotFather chat open — you'll paste the token from there in the next step. + + + Or scan to open BotFather on your phone: + {botfatherQr} + Press Enter when you have your bot token → + + ); +} + +function DashboardChannelReadyContent({ + channel, + agentDetailsUrl, + onContinue, +}: { + channel: ChannelChoice; + agentDetailsUrl: string; + onContinue: () => void; +}): React.ReactElement { + useInput((_input, key) => { + if (key.return || _input === ' ') onContinue(); + }); + + const channelLabel = channelDisplayName(channel); + + return ( + + Continue in Novu Connect + + {channelLabel} setup is not available in the CLI yet. Press Enter to open your agent in Novu Connect and finish + connecting there. + + {agentDetailsUrl} + Press Enter to open Novu Connect → + + ); +} + +function SuccessView({ + phase, +}: { + phase: Extract, { kind: 'success' }>; +}): React.ReactElement { + const { agent, connectDashboardUrl, environmentSlug, connectedChannel, dashboardRedirectChannel } = phase; + const agentUrl = environmentSlug + ? `${connectDashboardUrl}/env/${environmentSlug}/connect/agents/${encodeURIComponent(agent.identifier)}` + : `${connectDashboardUrl}/connect/agents/${encodeURIComponent(agent.identifier)}`; + + const channelLabel = (() => { + if (connectedChannel === 'slack') return 'Slack'; + if (connectedChannel === 'telegram') return 'Telegram'; + if (connectedChannel === 'email') return 'Email'; + + return null; + })(); + const redirectChannelLabel = dashboardRedirectChannel ? channelDisplayName(dashboardRedirectChannel) : null; + + return ( + + ✓ Your agent is live. + + + Agent: {agent.name} ({agent.identifier}) + + {renderSuccessChannelMessage(channelLabel, redirectChannelLabel)} + + Dashboard: {agentUrl} + + + + ); +} + +function renderSuccessChannelMessage( + channelLabel: string | null, + redirectChannelLabel: string | null +): React.ReactElement { + if (channelLabel) { + return Check {channelLabel} — your agent just messaged you.; + } + + if (redirectChannelLabel) { + return ( + Finish {redirectChannelLabel} setup in Novu Connect — we opened it for you. + ); + } + + return No channel connected. Run `npx novu connect` again to wire one up.; +} diff --git a/packages/novu/src/commands/connect/ui/preview-field-config.ts b/packages/novu/src/commands/connect/ui/preview-field-config.ts new file mode 100644 index 00000000000..067cefe4568 --- /dev/null +++ b/packages/novu/src/commands/connect/ui/preview-field-config.ts @@ -0,0 +1,143 @@ +import type { GeneratedAgentSpec } from '../api/agents'; +import { + buildMcpSelectOptions, + buildSkillSelectOptions, + buildToolSelectOptions, + type CatalogSelectOption, + formatCapabilitySummary, + resolveGeneratedAgentSpecLabels, + slugifyAgentIdentifier, +} from './agent-spec-labels'; + +export type PreviewTextFieldId = 'name' | 'identifier' | 'systemPrompt'; +export type PreviewMultiFieldId = 'tools' | 'mcpServers' | 'skills'; +export type PreviewEditableFieldId = PreviewTextFieldId | PreviewMultiFieldId; +export type PreviewActionId = 'create' | 'regenerate'; + +export type PreviewFieldRow = + | { id: PreviewTextFieldId; kind: 'text'; label: string } + | { id: PreviewMultiFieldId; kind: 'multi'; label: string } + | { id: PreviewActionId; kind: 'action'; label: string; hint?: string }; + +export const PREVIEW_FIELD_ROWS: PreviewFieldRow[] = [ + { id: 'name', kind: 'text', label: 'Name' }, + { id: 'identifier', kind: 'text', label: 'Identifier' }, + { id: 'systemPrompt', kind: 'text', label: 'System prompt' }, + { id: 'tools', kind: 'multi', label: 'Tools' }, + { id: 'mcpServers', kind: 'multi', label: 'MCP' }, + { id: 'skills', kind: 'multi', label: 'Skills' }, + { id: 'create', kind: 'action', label: 'Create this agent', hint: '→' }, + { id: 'regenerate', kind: 'action', label: 'Regenerate from description', hint: '↺' }, +]; + +export const PREVIEW_CREATE_ROW_INDEX = PREVIEW_FIELD_ROWS.findIndex((row) => row.id === 'create'); +export const PREVIEW_FIELD_LABEL_WIDTH = 15; + +type PreviewFieldUpdateResult = { + draft: GeneratedAgentSpec; + identifierTouched?: boolean; +}; + +export function getPreviewFieldLabel(fieldId: PreviewEditableFieldId): string { + const row = PREVIEW_FIELD_ROWS.find((item) => item.id === fieldId); + + return row!.label; +} + +export function readPreviewFieldValue( + fieldId: PreviewEditableFieldId, + draft: GeneratedAgentSpec +): string { + if (fieldId === 'name') return draft.name; + if (fieldId === 'identifier') return draft.identifier; + if (fieldId === 'systemPrompt') return draft.systemPrompt; + + const labels = resolveGeneratedAgentSpecLabels(draft); + if (fieldId === 'tools') return formatCapabilitySummary(labels.tools); + if (fieldId === 'mcpServers') return formatCapabilitySummary(labels.mcpServers); + + return formatCapabilitySummary(labels.skills); +} + +export function readPreviewTextDefaultValue(fieldId: PreviewTextFieldId, draft: GeneratedAgentSpec): string { + if (fieldId === 'name') return draft.name; + if (fieldId === 'identifier') return draft.identifier; + + return draft.systemPrompt; +} + +export function readPreviewTextPlaceholder(fieldId: PreviewTextFieldId): string { + if (fieldId === 'name') return 'Customer Support Agent'; + if (fieldId === 'identifier') return 'customer-support-agent'; + + return 'You are a helpful agent that…'; +} + +export function readPreviewMultiOptions(fieldId: PreviewMultiFieldId): CatalogSelectOption[] { + if (fieldId === 'tools') return buildToolSelectOptions(); + if (fieldId === 'mcpServers') return buildMcpSelectOptions(); + + return buildSkillSelectOptions(); +} + +export function readPreviewMultiDefaultValue(fieldId: PreviewMultiFieldId, draft: GeneratedAgentSpec): string[] { + if (fieldId === 'tools') return draft.tools; + if (fieldId === 'mcpServers') return draft.mcpServers; + + return draft.skills.map((skill) => skill.skillId); +} + +export function applyPreviewTextEdit( + fieldId: PreviewTextFieldId, + draft: GeneratedAgentSpec, + value: string, + identifierTouched: boolean +): PreviewFieldUpdateResult { + const trimmed = value.trim(); + + if (fieldId === 'name') { + return { + draft: { + ...draft, + name: trimmed, + identifier: identifierTouched ? draft.identifier : slugifyAgentIdentifier(trimmed), + }, + }; + } + + if (fieldId === 'identifier') { + return { + draft: { + ...draft, + identifier: slugifyAgentIdentifier(trimmed), + }, + identifierTouched: true, + }; + } + + return { + draft: { + ...draft, + systemPrompt: value.trimEnd(), + }, + }; +} + +export function applyPreviewMultiEdit( + fieldId: PreviewMultiFieldId, + draft: GeneratedAgentSpec, + values: string[] +): GeneratedAgentSpec { + if (fieldId === 'tools') { + return { ...draft, tools: values }; + } + + if (fieldId === 'mcpServers') { + return { ...draft, mcpServers: values }; + } + + return { + ...draft, + skills: values.map((skillId) => ({ skillId })), + }; +} diff --git a/packages/novu/src/commands/connect/ui/preview-generated-content.tsx b/packages/novu/src/commands/connect/ui/preview-generated-content.tsx new file mode 100644 index 00000000000..8e48668dc6c --- /dev/null +++ b/packages/novu/src/commands/connect/ui/preview-generated-content.tsx @@ -0,0 +1,388 @@ +import { MultiSelect, TextInput } from '@inkjs/ui'; +import { validateManagedAgentSpec, MAX_GENERATED_MCP_SERVERS, MAX_GENERATED_SKILLS } from '@novu/shared'; +import { Box, Text, useInput, useStdout } from 'ink'; +// biome-ignore lint/correctness/noUnusedImports: classic-JSX linter falls back here because tsconfig.json excludes ui/. +import React from 'react'; +import type { GeneratedAgentSpec } from '../api/agents'; +import { wrapPreviewLines } from './agent-spec-labels'; +import { + applyPreviewMultiEdit, + applyPreviewTextEdit, + getPreviewFieldLabel, + PREVIEW_CREATE_ROW_INDEX, + PREVIEW_FIELD_LABEL_WIDTH, + PREVIEW_FIELD_ROWS, + readPreviewFieldValue, + readPreviewMultiDefaultValue, + readPreviewMultiOptions, + readPreviewTextDefaultValue, + readPreviewTextPlaceholder, + type PreviewFieldRow, + type PreviewMultiFieldId, + type PreviewTextFieldId, +} from './preview-field-config'; +import type { GeneratedAgentPreviewResult } from './ui'; + +type PreviewUiState = + | { kind: 'browse'; focusIdx: number } + | { kind: 'edit-text'; fieldId: PreviewTextFieldId } + | { kind: 'edit-multi'; fieldId: PreviewMultiFieldId }; + +export function PreviewGeneratedContent({ + spec, + onResolve, + morphComplete, +}: { + spec: GeneratedAgentSpec; + onResolve: (result: GeneratedAgentPreviewResult) => void; + morphComplete: boolean; +}): React.ReactElement { + const { stdout } = useStdout(); + const contentWidth = Math.max(48, Math.min(72, stdout.columns - 6)); + const [draft, setDraft] = React.useState(() => cloneSpec(spec)); + const [identifierTouched, setIdentifierTouched] = React.useState(false); + const [uiState, setUiState] = React.useState({ + kind: 'browse', + focusIdx: PREVIEW_CREATE_ROW_INDEX, + }); + const [validationError, setValidationError] = React.useState(null); + + const focusedRow = uiState.kind === 'browse' ? PREVIEW_FIELD_ROWS[uiState.focusIdx] : null; + + React.useEffect(() => { + setDraft(cloneSpec(spec)); + setIdentifierTouched(false); + setUiState({ kind: 'browse', focusIdx: PREVIEW_CREATE_ROW_INDEX }); + setValidationError(null); + }, [spec]); + + useInput((_input, key) => { + if (!morphComplete || uiState.kind !== 'browse' || !focusedRow) { + return; + } + + if (key.upArrow) { + setUiState({ + kind: 'browse', + focusIdx: (uiState.focusIdx - 1 + PREVIEW_FIELD_ROWS.length) % PREVIEW_FIELD_ROWS.length, + }); + setValidationError(null); + } else if (key.downArrow) { + setUiState({ + kind: 'browse', + focusIdx: (uiState.focusIdx + 1) % PREVIEW_FIELD_ROWS.length, + }); + setValidationError(null); + } else if (key.return) { + handleBrowseActivate(focusedRow); + } + }); + + function handleBrowseActivate(row: PreviewFieldRow): void { + if (row.kind === 'action') { + if (row.id === 'create') { + confirmDraft(); + + return; + } + + onResolve({ action: 'refine' }); + + return; + } + + setValidationError(null); + setUiState( + row.kind === 'text' + ? { kind: 'edit-text', fieldId: row.id } + : { kind: 'edit-multi', fieldId: row.id } + ); + } + + function confirmDraft(): void { + const normalized = normalizeDraft(draft); + const error = validateManagedAgentSpec(normalized); + + if (error) { + setValidationError(error); + + return; + } + + onResolve({ action: 'confirm', spec: normalized }); + } + + function finishTextEdit(value: string): void { + if (uiState.kind !== 'edit-text') { + return; + } + + const fieldId = uiState.fieldId; + const result = applyPreviewTextEdit(fieldId, draft, value, identifierTouched); + + setDraft(result.draft); + if (result.identifierTouched) { + setIdentifierTouched(true); + } + setValidationError(null); + setUiState({ kind: 'browse', focusIdx: PREVIEW_CREATE_ROW_INDEX }); + } + + function finishMultiEdit(values: string[]): void { + if (uiState.kind !== 'edit-multi') { + return; + } + + const fieldId = uiState.fieldId; + + if (fieldId === 'mcpServers' && values.length > MAX_GENERATED_MCP_SERVERS) { + setValidationError(`Select at most ${MAX_GENERATED_MCP_SERVERS} MCP servers.`); + + return; + } + + if (fieldId === 'skills' && values.length > MAX_GENERATED_SKILLS) { + setValidationError(`Select at most ${MAX_GENERATED_SKILLS} skills.`); + + return; + } + + setDraft((current) => applyPreviewMultiEdit(fieldId, current, values)); + setValidationError(null); + setUiState({ kind: 'browse', focusIdx: PREVIEW_CREATE_ROW_INDEX }); + } + + function cancelEdit(): void { + setUiState({ kind: 'browse', focusIdx: PREVIEW_CREATE_ROW_INDEX }); + setValidationError(null); + } + + if (!morphComplete) { + return ( + + Shaping your agent… + + ); + } + + if (uiState.kind === 'edit-text') { + return ( + + ); + } + + if (uiState.kind === 'edit-multi') { + return ( + + ); + } + + const promptPreview = wrapPreviewLines(draft.systemPrompt, contentWidth - PREVIEW_FIELD_LABEL_WIDTH - 2, 3); + + return ( + + + Your agent, shaped + + ↑ adjust fields · Enter to create + + + {PREVIEW_FIELD_ROWS.map((row, index) => { + const isFocused = index === uiState.focusIdx; + const isPrimaryAction = row.id === 'create'; + + if (row.kind === 'action') { + const showActionDivider = index === PREVIEW_CREATE_ROW_INDEX; + + return ( + + {showActionDivider ? Ready when you are : null} + {isPrimaryAction ? ( + + + {isFocused ? '› ' : ' '} + {row.label} + + + {isFocused ? ` ${row.hint ?? ''}` : ' · Enter'} + + + ) : ( + + + {isFocused ? '› ' : ' '} + {row.label} + + {isFocused && row.hint ? {` ${row.hint}`} : null} + + )} + + ); + } + + if (row.id === 'systemPrompt') { + return ( + + + + {promptPreview.lines.map((line, lineIndex) => ( + + {line} + + ))} + {promptPreview.truncated ? : null} + + + ); + } + + const value = readPreviewFieldValue(row.id, draft); + + return ( + + ); + })} + + + {validationError ? {validationError} : null} + + ); +} + +function PreviewFieldLabel({ label, focused }: { label: string; focused: boolean }): React.ReactElement { + return ( + + {focused ? '› ' : ' '} + {label} + + ); +} + +function PreviewFieldRow({ + label, + value, + focused, +}: { + label: string; + value: string; + focused: boolean; +}): React.ReactElement { + return ( + + {focused ? '› ' : ' '} + {`${label.padEnd(PREVIEW_FIELD_LABEL_WIDTH)}`} + {value} + + ); +} + +function PreviewTextEditor({ + fieldId, + draft, + contentWidth, + onSubmit, + onCancel, +}: { + fieldId: PreviewTextFieldId; + draft: GeneratedAgentSpec; + contentWidth: number; + onSubmit: (value: string) => void; + onCancel: () => void; +}): React.ReactElement { + const title = getPreviewFieldLabel(fieldId); + const defaultValue = readPreviewTextDefaultValue(fieldId, draft); + const placeholder = readPreviewTextPlaceholder(fieldId); + + useInput((_input, key) => { + if (key.escape) { + onCancel(); + } + }); + + return ( + + {`Edit ${title.toLowerCase()}`} + Enter save · Esc cancel + + + + {fieldId === 'identifier' ? Lowercase kebab-case · synced from name until you edit it. : null} + + ); +} + +function PreviewMultiEditor({ + fieldId, + draft, + contentWidth, + onSubmit, + onCancel, + validationError, +}: { + fieldId: PreviewMultiFieldId; + draft: GeneratedAgentSpec; + contentWidth: number; + onSubmit: (values: string[]) => void; + onCancel: () => void; + validationError: string | null; +}): React.ReactElement { + const title = getPreviewFieldLabel(fieldId); + const options = readPreviewMultiOptions(fieldId); + const defaultValue = readPreviewMultiDefaultValue(fieldId, draft); + const limitHint = (() => { + if (fieldId === 'mcpServers') return `Select up to ${MAX_GENERATED_MCP_SERVERS}.`; + if (fieldId === 'skills') return `Select up to ${MAX_GENERATED_SKILLS}.`; + + return 'Toggle with space · Enter when done.'; + })(); + + useInput((_input, key) => { + if (key.escape) { + onCancel(); + } + }); + + return ( + + {`Edit ${title.toLowerCase()}`} + {`${limitHint} Esc cancel.`} + + {validationError ? {validationError} : null} + + ); +} + +function cloneSpec(spec: GeneratedAgentSpec): GeneratedAgentSpec { + return { + name: spec.name, + identifier: spec.identifier, + systemPrompt: spec.systemPrompt, + tools: [...spec.tools], + mcpServers: [...spec.mcpServers], + skills: spec.skills.map((skill) => ({ skillId: skill.skillId })), + }; +} + +function normalizeDraft(spec: GeneratedAgentSpec): GeneratedAgentSpec { + return { + name: spec.name.trim(), + identifier: spec.identifier.trim(), + systemPrompt: spec.systemPrompt.trim(), + tools: [...spec.tools], + mcpServers: [...spec.mcpServers], + skills: spec.skills.map((skill) => ({ skillId: skill.skillId.trim() })).filter((skill) => skill.skillId), + }; +} diff --git a/packages/novu/src/commands/connect/ui/store.ts b/packages/novu/src/commands/connect/ui/store.ts index a269b24f490..0ff2045ee25 100644 --- a/packages/novu/src/commands/connect/ui/store.ts +++ b/packages/novu/src/commands/connect/ui/store.ts @@ -1,6 +1,7 @@ import { atom, type WritableAtom } from 'nanostores'; -import type { AgentSummary, ChannelChoice } from '../types'; -import type { PickResult } from './ui'; +import type { GeneratedAgentSpec } from '../api/agents'; +import type { AgentRuntimeChoice, AgentSummary, ChannelChoice } from '../types'; +import type { GeneratedAgentPreviewResult, PickAgentIntegrationResult, PickResult } from './ui'; export type Phase = | { @@ -12,10 +13,45 @@ export type Phase = | { kind: 'listing-agents' } | { kind: 'loading-integrations' } | { kind: 'pick'; agents: AgentSummary[]; resolve: (pick: PickResult) => void } - | { kind: 'describe'; resolve: (prompt: string) => void } + | { + kind: 'pick-runtime'; + preselected?: AgentRuntimeChoice; + resolve: (runtime: AgentRuntimeChoice) => void; + } + | { + kind: 'pick-integration'; + providerLabel: string; + integrations: Array<{ _id: string; name: string; identifier: string }>; + resolve: (pick: PickAgentIntegrationResult) => void; + } + | { + kind: 'prompt-secret'; + title: string; + placeholder: string; + hint?: string; + secret?: boolean; + resolve: (value: string) => void; + } + | { + kind: 'pick-aws-region'; + resolve: (region: string) => void; + } + | { kind: 'verifying-credentials' } + | { kind: 'describe'; previousPrompt?: string; resolve: (prompt: string) => void } | { kind: 'generating' } + | { + kind: 'preview-generated'; + spec: GeneratedAgentSpec; + resolve: (result: GeneratedAgentPreviewResult) => void; + } | { kind: 'creating'; name: string } | { kind: 'pick-channel'; resolve: (choice: ChannelChoice) => void } + | { + kind: 'dashboard-channel-ready'; + channel: ChannelChoice; + agentDetailsUrl: string; + resolve: () => void; + } | { kind: 'adding-slack' } | { kind: 'paste-slack-token'; @@ -24,6 +60,14 @@ export type Phase = reject: (reason: Error) => void; } | { kind: 'running-slack-quick-setup' } + | { + kind: 'slack-oauth-ready'; + authorizeUrl: string; + /** True when Novu just created the Slack app via manifest quick-setup. */ + appCreated: boolean; + /** Resolves when the user hits Enter — the pipeline then runs `open()`. */ + resolve: () => void; + } | { kind: 'waiting-slack'; authorizeUrl: string; pollingStartedAt: number } | { kind: 'adding-email' } | { @@ -64,9 +108,12 @@ export type Phase = kind: 'success'; agent: AgentSummary; dashboardUrl: string; + connectDashboardUrl: string; environmentSlug: string | null; /** Which channel ended up connected, if any. Drives the "check your bot" copy on the final screen. */ connectedChannel: ChannelChoice | null; + /** Channel the user picked that continues in the Connect dashboard instead of the CLI. */ + dashboardRedirectChannel: ChannelChoice | null; } | { kind: 'error'; message: string }; diff --git a/packages/novu/src/commands/connect/ui/ui.ts b/packages/novu/src/commands/connect/ui/ui.ts index ffaa3837075..a299bf04ce7 100644 --- a/packages/novu/src/commands/connect/ui/ui.ts +++ b/packages/novu/src/commands/connect/ui/ui.ts @@ -1,7 +1,16 @@ -import type { AgentSummary, ChannelChoice } from '../types'; +import type { GeneratedAgentSpec } from '../api/agents'; +import type { AgentRuntimeChoice, AgentSummary, ChannelChoice } from '../types'; export type PickResult = { action: 'new' } | { action: 'use'; agent: AgentSummary }; +export type GeneratedAgentPreviewResult = + | { action: 'confirm'; spec: GeneratedAgentSpec } + | { action: 'refine' }; + +export type PickAgentIntegrationResult = + | { kind: 'existing'; integrationId: string } + | { kind: 'new' }; + export interface ConnectUI { // Welcome screen /** @@ -24,16 +33,46 @@ export interface ConnectUI { loadingIntegrations(): void; pickExistingOrCreate(agents: AgentSummary[]): Promise; + // Agent runtime / credentials (new-agent path) + pickAgentRuntime(opts: { preselected?: AgentRuntimeChoice }): Promise; + pickAgentIntegration(opts: { + providerLabel: string; + integrations: Array<{ _id: string; name: string; identifier: string }>; + }): Promise; + promptForSecretInput(opts: { + title: string; + placeholder: string; + hint?: string; + secret?: boolean; + }): Promise; + pickAwsClaudeRegion(): Promise; + verifyingCredentials(): void; + credentialsVerified(): void; + // Create-new path promptForDescription(defaultPrompt?: string): Promise; + /** + * Re-prompt for the agent description after the user chooses to refine a + * generated preview. Shows the previous prompt for context. + */ + refineDescription(previousPrompt: string): Promise; generatingAgent(): void; + /** + * Preview and optionally edit the AI-generated agent spec before provisioning. + * Resolves with the confirmed spec or a request to refine the source description. + */ + previewGeneratedAgent(spec: GeneratedAgentSpec): Promise; creatingAgent(name: string): void; agentCreated(agent: AgentSummary): void; // Channel selection pickChannel(): Promise; - /** Render a "coming soon" message for a channel that isn't wired up yet. */ - channelComingSoon(choice: ChannelChoice): void; + /** + * Unsupported-in-CLI channels open the Connect dashboard agent page so the + * user can finish setup there. Resolves when the user hits Enter — the + * pipeline then runs `open(agentDetailsUrl)`. + */ + awaitDashboardChannelOpen(opts: { channel: ChannelChoice; agentDetailsUrl: string }): Promise; // Email path addingEmailIntegration(): void; @@ -82,8 +121,17 @@ export interface ConnectUI { */ promptForSlackConfigToken(opts: { retry: boolean }): Promise; runningSlackQuickSetup(): void; - showSlackOAuthUrl(url: string): void; - pollingForSlackConnection(): void; + /** + * Consent gate before opening Slack OAuth. When `appCreated` is true, confirms + * the manifest quick-setup succeeded before asking the user to install the app + * in their workspace. Resolves when the user hits Enter — the pipeline then + * runs `open()`. + */ + awaitSlackOAuthOpen(opts: { authorizeUrl: string; appCreated: boolean }): Promise; + /** + * Transitions to the polling view. Fired by the pipeline right after `open()`. + */ + showSlackWaiting(opts: { authorizeUrl: string }): void; slackConnected(): void; slackSkipped(): void; @@ -94,8 +142,10 @@ export interface ConnectUI { success(result: { agent: AgentSummary; dashboardUrl: string; + connectDashboardUrl: string; environmentSlug: string | null; connectedChannel: ChannelChoice | null; + dashboardRedirectChannel: ChannelChoice | null; }): void; failure(message: string): void; diff --git a/packages/novu/src/commands/connect/ui/welcome-content.tsx b/packages/novu/src/commands/connect/ui/welcome-content.tsx new file mode 100644 index 00000000000..63ce0a9a3ff --- /dev/null +++ b/packages/novu/src/commands/connect/ui/welcome-content.tsx @@ -0,0 +1,267 @@ +import { Box, Text, useInput } from 'ink'; +// biome-ignore lint/correctness/noUnusedImports: classic-JSX linter falls back here because tsconfig.json excludes ui/. +import React from 'react'; + +/** + * First screen the user sees. The reveal is timed against the orb's entry + * animation so it doesn't compete: the orb plays for ENTRY_MS, then after a + * short hold the welcome text materializes through a dithered cascade + * (`· → ░ → ▒ → ▓ → real char` per position) matching the orb's own + * dithered aesthetic. Enter is ignored until the cascade completes — a + * fast key-mash during the reveal won't skip past it. + */ +const WELCOME_REVEAL_START_MS = 1300; +const WELCOME_REVEAL_DURATION_MS = 900; +const WELCOME_REVEAL_TOTAL_MS = WELCOME_REVEAL_START_MS + WELCOME_REVEAL_DURATION_MS; +const WELCOME_FRAME_MS = 55; + +const WELCOME_AGENT_ROTATIONS: ReadonlyArray = [ + 'a Claude Managed Agent', + 'a Google Vertex AI Agent', + 'an AI SDK Agent', + 'a Claude Managed Agent on AWS', +]; + +const WELCOME_CHANNELS_LABEL = 'Slack, Telegram, MS Teams'; + +/** Time each label stays fully readable before the next dither transition. */ +const WELCOME_SWAP_HOLD_MS = 5200; +/** Dither-out + dither-in duration for each label change. */ +const WELCOME_SWAP_TRANSITION_MS = 600; + +export function WelcomeContent({ onContinue }: { onContinue: () => void }): React.ReactElement { + const [elapsed, setElapsed] = React.useState(0); + const bornAtRef = React.useRef(Date.now()); + + React.useEffect(() => { + const t = setInterval(() => { + const e = Date.now() - bornAtRef.current; + setElapsed(e); + if (e >= WELCOME_REVEAL_TOTAL_MS) clearInterval(t); + }, WELCOME_FRAME_MS); + + return () => clearInterval(t); + }, []); + + const revealComplete = elapsed >= WELCOME_REVEAL_TOTAL_MS; + // 0..1 progress through the dither cascade. Negative values (during the + // hold before the cascade starts) clamp to 0 so DitherText renders the + // pre-reveal noise state. + const progress = Math.min(1, Math.max(0, (elapsed - WELCOME_REVEAL_START_MS) / WELCOME_REVEAL_DURATION_MS)); + const startedRevealing = elapsed >= WELCOME_REVEAL_START_MS; + + useInput((_input, key) => { + if (!revealComplete) return; + if (key.return || _input === ' ') onContinue(); + }); + + // Reserve the same vertical space throughout — three lines with a blank + // between each (matching `gap={1}` on the Box) — so the layout doesn't + // jump when the cascade kicks off. + // + // `alignItems="center"` keeps every line centered WITHIN the Welcome Box. + // Without it, the headline left-aligns to whatever child is widest — so + // when the tagline (longest line) appears, the box widens and the + // headline visually slides left. With centering, each line individually + // centers and the headline stays in place. + if (!startedRevealing) { + return ( + + + + + + + ); + } + + return ( + + + {revealComplete ? ( + <> + + Press Enter to sign in or create an account → + + ) : ( + // Hold the layout open while the headline finishes dithering so the + // CTA doesn't shove up into view mid-cascade. + <> + + + + + )} + + ); +} + +function WelcomeAnimatedTagline(): React.ReactElement { + const agentSlotWidth = maxLabelLength(WELCOME_AGENT_ROTATIONS); + + return ( + + + Spin up + + + + + + and connect it to + + {WELCOME_CHANNELS_LABEL} + + and more — all from your terminal + + + ); +} + +/** + * Cycles through `items`, dithering the current label out before the next one + * materializes in. Slow hold + slow transition so the orb screen stays calm. + */ +function DitherSwapText({ + items, + seed, + holdMs, + transitionMs = WELCOME_SWAP_TRANSITION_MS, + startOffsetMs = 0, +}: { + items: ReadonlyArray; + seed: number; + holdMs: number; + transitionMs?: number; + startOffsetMs?: number; +}): React.ReactElement { + const [index, setIndex] = React.useState(0); + const [progress, setProgress] = React.useState(1); + const phaseRef = React.useRef<'hold' | 'out' | 'in'>('hold'); + const indexRef = React.useRef(0); + const phaseStartedAtRef = React.useRef(Date.now() + startOffsetMs); + const startedRef = React.useRef(startOffsetMs <= 0); + + React.useEffect(() => { + indexRef.current = index; + }, [index]); + + React.useEffect(() => { + const tick = () => { + const now = Date.now(); + if (!startedRef.current) { + if (now < phaseStartedAtRef.current) { + return; + } + startedRef.current = true; + phaseStartedAtRef.current = now; + } + + const elapsed = now - phaseStartedAtRef.current; + + if (phaseRef.current === 'hold') { + setProgress(1); + if (elapsed >= holdMs) { + phaseRef.current = 'out'; + phaseStartedAtRef.current = now; + } + + return; + } + + if (phaseRef.current === 'out') { + const outProgress = Math.min(1, elapsed / transitionMs); + setProgress(1 - outProgress); + if (outProgress >= 1) { + const nextIndex = (indexRef.current + 1) % items.length; + indexRef.current = nextIndex; + setIndex(nextIndex); + phaseRef.current = 'in'; + phaseStartedAtRef.current = now; + setProgress(0); + } + + return; + } + + const inProgress = Math.min(1, elapsed / transitionMs); + setProgress(inProgress); + if (inProgress >= 1) { + phaseRef.current = 'hold'; + phaseStartedAtRef.current = now; + setProgress(1); + } + }; + + tick(); + const timer = setInterval(tick, WELCOME_FRAME_MS); + + return () => clearInterval(timer); + }, [holdMs, items.length, transitionMs, startOffsetMs]); + + const padded = items[index]; + + return ( + + {renderDitherString(padded, progress, seed)} + + ); +} + +function maxLabelLength(items: ReadonlyArray): number { + return items.reduce((longest, item) => Math.max(longest, item.length), 0); +} + +/** + * Render `text` mid-materialization. Each non-space character gets a + * deterministic "reveal time" in [0, 1) via a small integer hash on its + * position + per-line `seed`. When `progress` crosses that threshold the + * character settles into its real glyph; before that it shows a dither + * glyph whose density tracks how far away from settling we still are. Same + * Bayer-style aesthetic the orb uses, but applied to text. + */ +function renderDitherString(text: string, progress: number, seed: number): string { + let rendered = ''; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === ' ') { + rendered += ' '; + continue; + } + const hash = (((i + 1) * (seed * 2654435761 + 1)) >>> 0) / 0xffffffff; + if (progress >= hash) { + rendered += ch; + continue; + } + const distance = hash - progress; + if (distance > 0.55) rendered += ' '; + else if (distance > 0.35) rendered += '·'; + else if (distance > 0.2) rendered += '░'; + else if (distance > 0.08) rendered += '▒'; + else rendered += '▓'; + } + + return rendered; +} + +function DitherText({ + text, + progress, + seed, + bold, + dim, + color, +}: { + text: string; + progress: number; + seed: number; + bold?: boolean; + dim?: boolean; + color?: string; +}): React.ReactElement { + return ( + + {renderDitherString(text, progress, seed)} + + ); +} diff --git a/packages/novu/src/index.ts b/packages/novu/src/index.ts index a9e9c85f20c..35a3132ba33 100644 --- a/packages/novu/src/index.ts +++ b/packages/novu/src/index.ts @@ -5,7 +5,7 @@ import { v4 as uuidv4 } from 'uuid'; import { DevCommandOptions, devCommand } from './commands'; import { connectCommand } from './commands/connect'; import { resolveConnectCommandOptions } from './commands/connect/resolve-options'; -import { CHANNEL_CHOICES, type ChannelChoice } from './commands/connect/types'; +import { AGENT_RUNTIME_CHOICES, CHANNEL_CHOICES, type AgentRuntimeChoice, type ChannelChoice } from './commands/connect/types'; import type { ConnectCommandInput } from './commands/connect/resolve-options'; import { CloudRegionEnum } from './commands/dev/enums'; import { IInitCommandOptions, init } from './commands/init'; @@ -144,6 +144,18 @@ program CloudRegionEnum.US ) .option('--prompt ', 'Pre-fill the agent description (skips the input screen)') + .option( + '--runtime ', + `Agent runtime for new agents (${AGENT_RUNTIME_CHOICES.join(' | ')}). Defaults to demo in interactive mode.` + ) + .option( + '--agent-integration-id ', + 'Use an existing agent-runtime integration (skips credential setup for BYOK runtimes)' + ) + .option('--anthropic-api-key ', 'Anthropic API key for --runtime claude non-interactive runs') + .option('--aws-claude-api-key ', 'AWS Claude API key for --runtime claude-aws non-interactive runs') + .option('--aws-claude-region ', 'AWS Claude commercial region for --runtime claude-aws') + .option('--aws-claude-workspace-id ', 'AWS Claude workspace ID for --runtime claude-aws') .option( '--channel ', `Channel to connect (skips the picker). One of: ${CHANNEL_CHOICES.join(', ')}. "slack" and "telegram" are implemented today.` @@ -168,6 +180,12 @@ program console.error(`Invalid --channel value: "${options.channel}". Expected one of: ${CHANNEL_CHOICES.join(', ')}.`); process.exit(1); } + if (options.runtime && !(AGENT_RUNTIME_CHOICES as readonly string[]).includes(options.runtime)) { + console.error( + `Invalid --runtime value: "${options.runtime}". Expected one of: ${AGENT_RUNTIME_CHOICES.join(', ')}.` + ); + process.exit(1); + } let resolved: ReturnType; try { resolved = resolveConnectCommandOptions({ @@ -175,6 +193,7 @@ program region: options.region as CloudRegionEnum, prompt: positionalPrompt ?? options.prompt, channel: options.channel as ChannelChoice | undefined, + runtime: options.runtime as AgentRuntimeChoice | undefined, apiUrl: options.apiUrl ?? NOVU_API_URL, }); } catch (error) { diff --git a/packages/shared/src/consts/providers/index.ts b/packages/shared/src/consts/providers/index.ts index ff0a73fd772..200df3f0fe8 100644 --- a/packages/shared/src/consts/providers/index.ts +++ b/packages/shared/src/consts/providers/index.ts @@ -4,6 +4,7 @@ export * from './claude-skills'; export * from './claude-tools'; export * from './conversational-providers'; export * from './credentials'; +export * from './managed-agent-spec'; export * from './mcp-servers'; export * from './provider.interface'; export * from './providers'; diff --git a/packages/shared/src/consts/providers/managed-agent-spec.ts b/packages/shared/src/consts/providers/managed-agent-spec.ts new file mode 100644 index 00000000000..1ada302661d --- /dev/null +++ b/packages/shared/src/consts/providers/managed-agent-spec.ts @@ -0,0 +1,85 @@ +import { CLAUDE_ANTHROPIC_SKILLS } from './claude-skills'; +import { CLAUDE_BUILTIN_TOOLS } from './claude-tools'; +import { MCP_SERVERS } from './mcp-servers'; + +export const MAX_GENERATED_MCP_SERVERS = 5; +export const MAX_GENERATED_SKILLS = 4; +export const MANAGED_AGENT_NAME_MAX_LENGTH = 60; +export const MANAGED_AGENT_IDENTIFIER_MAX_LENGTH = 60; +export const MANAGED_AGENT_SYSTEM_PROMPT_MAX_LENGTH = 4000; + +const MANAGED_AGENT_IDENTIFIER_REGEX = /^[a-z0-9-]+$/; +const MANAGED_AGENT_TOOL_IDS = new Set(CLAUDE_BUILTIN_TOOLS.map((tool) => tool.type)); +const MANAGED_AGENT_MCP_IDS = new Set(MCP_SERVERS.map((server) => server.id)); +const MANAGED_AGENT_SKILL_IDS = new Set(CLAUDE_ANTHROPIC_SKILLS.map((skill) => skill.skillId)); + +export type ManagedAgentSpecInput = { + name: string; + identifier: string; + systemPrompt: string; + tools: string[]; + mcpServers: string[]; + skills: Array<{ skillId: string }>; +}; + +export function validateManagedAgentSpec(spec: ManagedAgentSpecInput): string | null { + const name = spec.name.trim(); + const identifier = spec.identifier.trim(); + const systemPrompt = spec.systemPrompt.trim(); + + if (!name) { + return 'Agent name is required.'; + } + + if (name.length > MANAGED_AGENT_NAME_MAX_LENGTH) { + return `Agent name must be ${MANAGED_AGENT_NAME_MAX_LENGTH} characters or fewer.`; + } + + if (!identifier) { + return 'Agent identifier is required.'; + } + + if (!MANAGED_AGENT_IDENTIFIER_REGEX.test(identifier)) { + return 'Identifier must be lowercase letters, numbers, and dashes only.'; + } + + if (identifier.length > MANAGED_AGENT_IDENTIFIER_MAX_LENGTH) { + return `Identifier must be ${MANAGED_AGENT_IDENTIFIER_MAX_LENGTH} characters or fewer.`; + } + + if (!systemPrompt) { + return 'System prompt is required.'; + } + + if (systemPrompt.length > MANAGED_AGENT_SYSTEM_PROMPT_MAX_LENGTH) { + return `System prompt must be ${MANAGED_AGENT_SYSTEM_PROMPT_MAX_LENGTH} characters or fewer.`; + } + + for (const toolId of spec.tools) { + if (!MANAGED_AGENT_TOOL_IDS.has(toolId)) { + return `Unknown tool "${toolId}".`; + } + } + + if (spec.mcpServers.length > MAX_GENERATED_MCP_SERVERS) { + return `Select at most ${MAX_GENERATED_MCP_SERVERS} MCP servers.`; + } + + for (const mcpId of spec.mcpServers) { + if (!MANAGED_AGENT_MCP_IDS.has(mcpId)) { + return `Unknown MCP server "${mcpId}".`; + } + } + + if (spec.skills.length > MAX_GENERATED_SKILLS) { + return `Select at most ${MAX_GENERATED_SKILLS} skills.`; + } + + for (const skill of spec.skills) { + if (!MANAGED_AGENT_SKILL_IDS.has(skill.skillId)) { + return `Unknown skill "${skill.skillId}".`; + } + } + + return null; +} From d45323aa3b13ae640191718eb009794b7a5399a4 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Thu, 28 May 2026 16:59:25 +0300 Subject: [PATCH 2/9] fix: ver --- packages/novu/package.json | 2 +- .../connect/pipeline/resolve-agent-runtime-integration.ts | 3 +-- packages/shared/package.json | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/novu/package.json b/packages/novu/package.json index 184bb481c8a..e7d1fb240a3 100644 --- a/packages/novu/package.json +++ b/packages/novu/package.json @@ -1,6 +1,6 @@ { "name": "novu", - "version": "2.8.1-rc.11", + "version": "2.8.1-rc.12", "description": "Novu CLI. Run Novu Studio and sync workflows with Novu Cloud", "main": "src/index.js", "publishConfig": { diff --git a/packages/novu/src/commands/connect/pipeline/resolve-agent-runtime-integration.ts b/packages/novu/src/commands/connect/pipeline/resolve-agent-runtime-integration.ts index 32f319ae4c0..fe9d07a3c7f 100644 --- a/packages/novu/src/commands/connect/pipeline/resolve-agent-runtime-integration.ts +++ b/packages/novu/src/commands/connect/pipeline/resolve-agent-runtime-integration.ts @@ -2,7 +2,6 @@ import { AgentRuntimeProviderIdEnum, buildManagedIntegrationCredentials, hasCompleteManagedCredentials, - IntegrationKindEnum, type ManagedCredentialFields, } from '@novu/shared'; import { @@ -15,7 +14,7 @@ import type { ConnectApiClient } from '../api/client'; import type { AgentRuntimeChoice, ConnectCommandOptions } from '../types'; import type { ConnectUI } from '../ui/ui'; -const AGENT_INTEGRATION_KIND = IntegrationKindEnum.AGENT; +const AGENT_INTEGRATION_KIND = 'agent' as const; export type ResolvedRuntimeIntegration = { integrationId: string; diff --git a/packages/shared/package.json b/packages/shared/package.json index af2c1597282..ba555907bac 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@novu/shared", - "version": "2.6.6", + "version": "2.6.7", "description": "", "scripts": { "start": "npm run start:dev", From 3916ee3869ab45944a43687cd6f42889e327fdf1 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Thu, 28 May 2026 17:00:02 +0300 Subject: [PATCH 3/9] fix: minor bump --- packages/shared/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/package.json b/packages/shared/package.json index ba555907bac..e2b74466675 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@novu/shared", - "version": "2.6.7", + "version": "2.7.0", "description": "", "scripts": { "start": "npm run start:dev", From 5df1a1d66e71a609473ac56e4c18636242833e72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Tymczuk?= Date: Thu, 28 May 2026 16:20:02 +0200 Subject: [PATCH 4/9] fix(dashboard): prefer real Anthropic credential over demo in Add agent fixes NV-7835 (#11336) Co-authored-by: Cursor Agent --- .../connectors/claude-managed-integrations.ts | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/apps/dashboard/src/components/agents/connectors/claude-managed-integrations.ts b/apps/dashboard/src/components/agents/connectors/claude-managed-integrations.ts index da6a06e14ed..35ad8203d58 100644 --- a/apps/dashboard/src/components/agents/connectors/claude-managed-integrations.ts +++ b/apps/dashboard/src/components/agents/connectors/claude-managed-integrations.ts @@ -1,6 +1,25 @@ import { AgentRuntimeProviderIdEnum, type IIntegration, IntegrationKindEnum } from '@novu/shared'; import { isDemoIntegration } from '@/components/integrations/components/utils/helpers'; +function compareClaudeManagedIntegrations(left: IIntegration, right: IIntegration): number { + const leftIsDemo = isDemoIntegration(left.providerId); + const rightIsDemo = isDemoIntegration(right.providerId); + + if (leftIsDemo && !rightIsDemo) { + return 1; + } + + if (!leftIsDemo && rightIsDemo) { + return -1; + } + + // MongoDB ObjectId's first 4 bytes (8 hex chars) encode the creation timestamp, + // so a lexicographic descending compare on `_id` yields newest-first ordering. + // This ensures the most recently added credential is what `getPreferredClaudeManagedIntegration` + // returns and what the connector dropdown surfaces at the top. + return right._id.localeCompare(left._id); +} + const CLAUDE_MANAGED_PROVIDER_IDS: ReadonlySet = new Set([ AgentRuntimeProviderIdEnum.NovuAnthropic, AgentRuntimeProviderIdEnum.Anthropic, @@ -41,17 +60,9 @@ export function getClaudeManagedAgentIntegrations( integrations: IIntegration[] | undefined, providerId?: AgentRuntimeProviderIdEnum ): IIntegration[] { - return (integrations ?? []).filter((integration) => isClaudeManagedAgentIntegration(integration, providerId)).sort((left, right) => { - if (left.providerId === AgentRuntimeProviderIdEnum.NovuAnthropic) { - return -1; - } - - if (right.providerId === AgentRuntimeProviderIdEnum.NovuAnthropic) { - return 1; - } - - return 0; - }); + return (integrations ?? []) + .filter((integration) => isClaudeManagedAgentIntegration(integration, providerId)) + .sort(compareClaudeManagedIntegrations); } export function getPreferredClaudeManagedIntegration( From 6851e60ad872c0595831cf286a77091e4e65358d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Tymczuk?= Date: Thu, 28 May 2026 16:21:15 +0200 Subject: [PATCH 5/9] fix(dashboard): prevent connect onboarding flash on refresh fixes NV-7840 (#11337) Co-authored-by: Cursor Agent --- .../dashboard/set-things-up-section.tsx | 6 ++--- .../dashboard/use-connect-setup-steps.ts | 24 ++++++++++++++----- .../src/pages/connect/connect-dashboard.tsx | 6 ++--- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/apps/dashboard/src/components/connect/dashboard/set-things-up-section.tsx b/apps/dashboard/src/components/connect/dashboard/set-things-up-section.tsx index e1c628719e0..f56fad6e507 100644 --- a/apps/dashboard/src/components/connect/dashboard/set-things-up-section.tsx +++ b/apps/dashboard/src/components/connect/dashboard/set-things-up-section.tsx @@ -104,12 +104,12 @@ function StepRow({ ); } -export function SetThingsUpSection({ isLoading }: { isLoading?: boolean }) { +export function SetThingsUpSection() { const navigate = useNavigate(); const { currentEnvironment } = useEnvironment(); - const { steps: hookSteps, isComplete } = useConnectSetupSteps(); + const { steps: hookSteps, shouldShowOnboarding, isLoading } = useConnectSetupSteps(); - if (isComplete) { + if (!shouldShowOnboarding) { return null; } diff --git a/apps/dashboard/src/components/connect/dashboard/use-connect-setup-steps.ts b/apps/dashboard/src/components/connect/dashboard/use-connect-setup-steps.ts index 1b068c66a8a..6d20e15c165 100644 --- a/apps/dashboard/src/components/connect/dashboard/use-connect-setup-steps.ts +++ b/apps/dashboard/src/components/connect/dashboard/use-connect-setup-steps.ts @@ -34,7 +34,12 @@ export type ConnectSetupStep = { export type UseConnectSetupStepsResult = { steps: ConnectSetupStep[]; isComplete: boolean; + /** True while prerequisite queries are still resolving onboarding state. */ isLoading: boolean; + /** True only when onboarding is incomplete and setup state has been resolved. */ + shouldShowOnboarding: boolean; + /** Drives welcome copy — avoids onboarding messaging flash for returning users while loading. */ + showOnboardingMessaging: boolean; }; const AGENTS_PEEK_PARAMS = { after: undefined, before: undefined, limit: 2, identifier: '' }; @@ -132,14 +137,21 @@ export function useConnectSetupSteps(): UseConnectSetupStepsResult { [addAgentCompleted, onlyAgent?.identifier, sendMessageCompleted, setupChannelCompleted, setupChannelCtaAvailable] ); - // Only block on agents loading — conversations / integrations errors should never hide the section. - // The persisted `CONNECT_ONBOARDING_COMPLETED` flag is written from `agent-details.tsx` once the - // user finishes setting up an agent there; here we only read it to keep the section hidden. - const isLoading = agentsQuery.isLoading; + const isOnboardingCompletedInStorage = localStorage.getItem(CONNECT_ONBOARDING_COMPLETED) === 'true'; + const hasResolvedAgents = agentsQuery.isSuccess || agentsQuery.isError; + const hasResolvedIntegrations = !onlyAgent || agentIntegrationsQuery.isSuccess || agentIntegrationsQuery.isError; + const hasResolvedConversations = !hasAgent || conversationsQuery.isSuccess || conversationsQuery.isError; + const isSetupResolved = hasResolvedAgents && hasResolvedIntegrations && hasResolvedConversations; + const isComplete = isOnboardingCompletedInStorage || agentSetupComplete || hasAnyConversation; + const isLoading = !isOnboardingCompletedInStorage && !isSetupResolved; + const shouldShowOnboarding = isSetupResolved && !isComplete; + const showOnboardingMessaging = isSetupResolved ? !isComplete : !isOnboardingCompletedInStorage; return { steps, - isComplete: localStorage.getItem(CONNECT_ONBOARDING_COMPLETED) === 'true' || agentSetupComplete, - isLoading: isLoading || !agentsQuery.data, + isComplete, + isLoading, + shouldShowOnboarding, + showOnboardingMessaging, }; } diff --git a/apps/dashboard/src/pages/connect/connect-dashboard.tsx b/apps/dashboard/src/pages/connect/connect-dashboard.tsx index 2c57cb1fa04..88e44f34f5a 100644 --- a/apps/dashboard/src/pages/connect/connect-dashboard.tsx +++ b/apps/dashboard/src/pages/connect/connect-dashboard.tsx @@ -9,17 +9,17 @@ import { DashboardLayout } from '@/components/dashboard-layout'; import { PageMeta } from '@/components/page-meta'; export function ConnectDashboardPage() { - const { isComplete, isLoading } = useConnectSetupSteps(); + const { isComplete, showOnboardingMessaging } = useConnectSetupSteps(); return ( <>
    - +
    - +